Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil
0b0dda65cc fix(mobile): save raw photo downloads that fail on android 2026-07-10 02:02:15 +06:00
74 changed files with 530 additions and 651 deletions

View File

@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'zulu'
java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -81,7 +81,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
image: ghcr.io/immich-app/mdq:main@sha256:2a64c7f045cb7b580fbdf3614d7d1805f5775fec453e3d1023764180efa8c70b
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: '/language:${{matrix.language}}'

View File

@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -1,56 +0,0 @@
name: Update F-Droid Repo
on:
release:
types: [published]
permissions: {}
jobs:
update-index:
runs-on: ubuntu-latest
if: ${{ !github.event.release.prerelease }}
permissions:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
sparse-checkout: mobile/pubspec.yaml
sparse-checkout-cone-mode: false
- name: Update F-Droid repo
env:
GITLAB_TOKEN: ${{ secrets.FDROID_REPO_TOKEN }}
RELEASE: ${{ toJSON(github.event.release) }}
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION_CODE=$(yq '.version' mobile/pubspec.yaml | cut -d+ -f2)
export VERSION_CODE
NEW_ENTRY=$(yq -P '
[.assets[] | select(.name == "app-release.apk")] as $matches |
with(select($matches | length == 0); error("app-release.apk not found in release assets")) |
{
"url": $matches[0].browser_download_url,
"sha256sum": ($matches[0].digest | sub("^sha256:", "")),
"date": (.published_at | split("T") | .[0]),
"version-code": env(VERSION_CODE)
} | .date tag= "!!timestamp"
' <<< "$RELEASE")
export NEW_ENTRY
git clone --depth 1 "https://oauth2:${GITLAB_TOKEN}@gitlab.futo.org/fdroid/repo-v2.git" fdroid-repo
cd fdroid-repo
yq -i '
with(select([.apks[] | select(."version-code" == env(VERSION_CODE))] | length > 0); error("version-code already in index")) |
.apks = [env(NEW_ENTRY)] + .apks
' apps/Immich/index.yml
git config user.name "Immich Release Bot"
git config user.email "bot@immich.app"
git commit -am "Immich: add version-code ${VERSION_CODE} (${TAG})"
git push

View File

@@ -20,7 +20,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5.6.0
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
with:
token: ${{ steps.token.outputs.token }}
mode: exactly

View File

@@ -17,6 +17,6 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
repo-token: ${{ steps.token.outputs.token }}

View File

@@ -73,7 +73,7 @@ jobs:
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Bump version
env:

View File

@@ -4,4 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil @agg23
/mobile/ @shenlong-tanwen @santoshakil

View File

@@ -1,30 +1,5 @@
# System Integrity
## Integrity report
At a [customizable interval](https://my.immich.app/admin/system-settings?isOpen=integrity-checks), Immich runs integrity checks to ensure that your library is still intact and there are no corrupt files.
There are three kind of issues Immich checks for:
- Untracked files: the path was found in Immich's directories but it is not referenced in Immich's database
- Missing files: the path is found in the Immich internal database, but does not actually exist on disk
- Checksum mismatches: the checksum of the file stored in Immich's database does not match the actual file's checksum anymore
All three run nightly at 3am by default. For the "Checksum files" check, there are additional time and progress limits, as those are the most taxing checks. With these additional limits, Immich can slowly check checksums of your files over the course of a couple of days.
You can see the results of these checks on the [maintenance](https://my.immich.app/admin/maintenance) page. Here, you can also trigger a full scan (a _check_) for specific jobs, or all of them. In addition, you can also _refresh_ checks. This will only look at items that have currently been reported on, and check if those have been fixed.
### Common causes
Most common are untracked files. In many cases those are corrupted thumbnails or encoded videos that have been partially generated at some point and never got cleaned up properly. These are usually fine to delete, as both can always be regenerated at a later point. Other files will need to be investigated on a case-by-case basis by checking they already exist in Immich and thinking about how they might have gotten untracked.
:::info
You might want to run the _missing_ jobs for thumbnails and encoded videos (https://my.immich.app/admin/queues) to make sure all your assets have proper thumbnails and encoded videos. Watch the server logs while running the jobs, in case there are any issues with some assets.
:::
Missing files are files where Immich references them internally, but they don't actually exist on disk in that location. It could be that you deleted a file on disk from the internal library (don't do that, Immich doesn't support it). It could also be that there are issues with your file storage. Please carefully investigate missing files, and never hesitate to reach out on [our Discord](https://discord.immich.app) if you have any questions!
Checksum mismatches are often indicative of file system corruption. It could also be that you previously edited a file from the internal library on the disk, which is also not supported and will cause a checksum mismatch. Again, the recommended action is to look at any reported item individually, check it out, try to remember if you changed it or some metadata on it at some point. If you edited the file, the supported resolution is to delete the mismatched asset from Immich and reupload it as a new asset.
## Folder checks
:::info
@@ -65,7 +40,7 @@ The above error messages show that the server has previously (successfully) writ
### Ignoring the checks
:::danger
:::warning
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
:::

View File

@@ -1,7 +1,7 @@
The mobile app can be downloaded from the following places:
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [GitHub Releases (APK)](https://github.com/immich-app/immich/releases)
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
- [F-Droid](https://app.futo.org/fdroid/repo/)
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
- [GitHub Releases (apk)](https://github.com/immich-app/immich/releases)

View File

@@ -1,7 +1,7 @@
[
{
"label": "v3.0.3",
"url": "https://docs.v3.0.3.archive.immich.app"
"label": "v3.0.2",
"url": "https://docs.v3.0.2.archive.immich.app"
},
{
"label": "v2.7.5",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.3",
"version": "3.0.2",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -796,22 +796,5 @@ describe('/albums', () => {
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
});
it('should not allow an editor to change the role of an owner', async () => {
const album = await utils.createAlbum(user1.accessToken, {
albumName: 'testAlbum',
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
});
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Editor);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user1.userId}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ role: AlbumUserRole.Editor });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User is owner'));
});
});
});

View File

@@ -1621,7 +1621,7 @@
"person": "Person",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, one {# year} other {# years}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",

View File

@@ -431,8 +431,8 @@
"transcoding_realtime_description": "Permet au transcodage d'être réalisé en temps réel durant la diffusion du flux de la vidéo. Active la bascule automatique entre résolutions, mais peut entraîner une latence importante de lecture et des microcoupures en fonction des capacités du serveur.",
"transcoding_realtime_enabled": "Activer la transcodification en temps réel",
"transcoding_realtime_enabled_description": "Si désactivé, le serveur refusera de démarrer de nouvelles sessions de transcodifications en temps réel.",
"transcoding_realtime_resolutions": "Résolutions",
"transcoding_realtime_resolutions_description": "Les résolutions proposées pour le transcodage en temps réel. Des résolutions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_resolutions": "Définitions",
"transcoding_realtime_resolutions_description": "Les définitions proposées pour le transcodage en temps réel. Des définitions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_video_codecs": "Codecs vidéo",
"transcoding_realtime_video_codecs_description": "Les codecs vidéo proposés pour le transcodage en temps réel. Les clients choisissent la meilleure option qu'ils supportent durant la lecture. AV1 est plus efficace que HEVC, qui est plus efficace que H.264. En utilisant l'accélération matérielle, sélectionnez seulement les codecs que l'accélérateur peut encoder. En utilisant le transcodage logiciel, notez que H.264 est plus rapide que AV1, qui est plus rapide que HEVC.",
"transcoding_reference_frames": "Trames de référence",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.3"
version = "3.0.2"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.3"
version = "3.0.2"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -18,6 +18,7 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.mediasave.MediaSavePlugin
import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
@@ -63,6 +64,7 @@ class MainActivity : FlutterFragmentActivity() {
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(MediaSavePlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)

View File

@@ -0,0 +1,97 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.mediasave
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object MediaSavePigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
private open class MediaSavePigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface MediaSaveApi {
fun saveToDownloads(filePath: String, title: String, relativePath: String?, callback: (Result<String?>) -> Unit)
companion object {
/** The codec used by MediaSaveApi. */
val codec: MessageCodec<Any?> by lazy {
MediaSavePigeonCodec()
}
/** Sets up an instance of `MediaSaveApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: MediaSaveApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val filePathArg = args[0] as String
val titleArg = args[1] as String
val relativePathArg = args[2] as String?
api.saveToDownloads(filePathArg, titleArg, relativePathArg) { result: Result<String?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MediaSavePigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MediaSavePigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -0,0 +1,102 @@
package app.alextran.immich.mediasave
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.embedding.engine.plugins.FlutterPlugin
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class MediaSavePlugin : FlutterPlugin, MediaSaveApi {
private var context: Context? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
MediaSaveApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
MediaSaveApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun saveToDownloads(
filePath: String,
title: String,
relativePath: String?,
callback: (Result<String?>) -> Unit,
) {
val context = context ?: run {
callback(Result.success(null))
return
}
ioScope.launch {
try {
callback(Result.success(insertIntoFiles(context, filePath, title, relativePath)))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
// Uses the Files collection, not Images: Images only accepts MIME types the
// platform knows and rejects raw formats like CR3, while Files accepts any
// type. The file lands under [relativePath] (Download/Immich), not the gallery.
private fun insertIntoFiles(
context: Context,
filePath: String,
title: String,
relativePath: String?,
): String? {
val resolver = context.contentResolver
val collection = MediaStore.Files.getContentUri("external")
val source = File(filePath)
// Anything reaching this fallback is a format the platform can't type, so
// store it as a generic binary. The file saves and stays openable.
val mimeType = "application/octet-stream"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, title)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
relativePath?.let { put(MediaStore.MediaColumns.RELATIVE_PATH, it) }
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(collection, values) ?: return null
try {
val out = resolver.openOutputStream(uri)
if (out == null) {
resolver.delete(uri, null, null)
return null
}
out.use { source.inputStream().use { input -> input.copyTo(it) } }
resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
return uri.lastPathSegment
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
}
val dir = File(Environment.getExternalStorageDirectory(), relativePath ?: Environment.DIRECTORY_DCIM).apply { mkdirs() }
val target = File(dir, title)
source.inputStream().use { input -> target.outputStream().use { input.copyTo(it) } }
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, title)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
@Suppress("DEPRECATION")
put(MediaStore.MediaColumns.DATA, target.absolutePath)
}
return resolver.insert(collection, values)?.lastPathSegment
}
}

View File

@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3056,
"android.injected.version.name" => "3.0.3",
"android.injected.version.code" => 3055,
"android.injected.version.name" => "3.0.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3056,
"android.injected.version.name" => "3.0.3",
"android.injected.version.code" => 3055,
"android.injected.version.name" => "3.0.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.0.3</string>
<string>3.0.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -26,6 +26,7 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const String kShareDownloadGroup = 'group_share';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -684,7 +685,16 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
}
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
List<Bucket> _generateBuckets(int count) {
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
}
return buckets;
}
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {

View File

@@ -0,0 +1,81 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class MediaSaveApi {
/// Constructor for [MediaSaveApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
MediaSaveApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<String?> saveToDownloads(String filePath, String title, String? relativePath) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.MediaSaveApi.saveToDownloads$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[filePath, title, relativePath]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as String?;
}
}

View File

@@ -99,8 +99,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case TimelineReloadEvent():
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset != _asset) {
_isZoomed = false;
_viewer.setZoomed(false);
setState(() => _asset = asset);
}
default:

View File

@@ -81,12 +81,6 @@ class AssetViewer extends ConsumerStatefulWidget {
}
class _AssetViewerState extends ConsumerState<AssetViewer> {
static const _viewerOverlayStyle = SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.light,
);
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
@@ -286,52 +280,49 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_setSystemUIMode(controls, details);
});
return AnnotatedRegion(
value: _viewerOverlayStyle,
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
],
),
),
],
),
);
}

View File

@@ -1,10 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/platform/media_save_api.g.dart';
import 'package:immich_mobile/utils/mime.utils.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final _mediaSaveApi = MediaSaveApi();
final fileMediaRepositoryProvider = Provider((ref) => const FileMediaRepository());
class FileMediaRepository {
@@ -25,8 +31,19 @@ class FileMediaRepository {
}
Future<AssetEntity?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
return entity;
try {
return await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
} on PlatformException catch (e) {
// Some formats (e.g. raw like CR3) have no MIME the platform recognises, so
// photo_manager falls back to `image/*` and MediaStore rejects the save.
// Save those to Downloads ourselves, where the Files collection takes any
// type. Anything else isn't ours to handle.
if (!CurrentPlatform.isAndroid || !isUnsupportedMimeError(e)) {
rethrow;
}
final id = await _mediaSaveApi.saveToDownloads(filePath, title ?? p.basename(filePath), 'Download/Immich');
return id == null ? null : AssetEntity(id: id, typeInt: 1, width: 0, height: 0);
}
}
Future<AssetEntity?> saveLivePhoto({required File image, required File video, required String title}) async {

View File

@@ -317,8 +317,6 @@ class BackgroundUploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
@@ -338,7 +336,8 @@ class BackgroundUploadService {
return null;
}
final fields = {'livePhotoVideoId': livePhotoVideoId};
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
@@ -101,7 +100,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -127,7 +126,7 @@ class ForegroundUploadService {
continue;
}
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
@@ -144,7 +143,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
@@ -234,8 +233,7 @@ class ForegroundUploadService {
await Future.wait(workerFutures);
}
@visibleForTesting
Future<void> uploadSingleAsset(
Future<void> _uploadSingleAsset(
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,

View File

@@ -0,0 +1,12 @@
import 'package:flutter/services.dart';
// True when a gallery save failed because MediaStore rejected the file's MIME.
// Android raises `Unsupported MIME type` for formats it can't type (e.g. raw
// like CR3), where photo_manager falls back to `image/*`. Matched on the detail
// string (case-insensitive) because photo_manager surfaces no distinct error
// code for it. Keeps `mime type` in the match so it doesn't catch unrelated
// `Unsupported*` errors (e.g. UnsupportedOperationException).
bool isUnsupportedMimeError(PlatformException e) {
final details = e.details;
return details is String && details.toLowerCase().contains('unsupported mime type');
}

View File

@@ -393,8 +393,10 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -496,8 +496,10 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -472,8 +472,10 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.3
- API version: 3.0.2
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -0,0 +1,21 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/media_save_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/mediasave/MediaSave.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.mediasave'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class MediaSaveApi {
// Saves a file to a MediaStore Files-collection entry under [relativePath]
// (e.g. Download/Immich). Fallback for when photo_manager can't save a file
// because the platform has no MIME for it (e.g. raw like CR3) and MediaStore
// rejects the `image/*` it falls back to; the Files collection accepts any
// type. Returns the new media id, or null on failure.
@async
String? saveToDownloads(String filePath, String title, String? relativePath);
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.3+3056
version: 3.0.2+3055
environment:
sdk: '>=3.12.0 <4.0.0'

View File

@@ -1,4 +1,3 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -8,5 +7,3 @@ class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
class MockConnectivityApi extends Mock implements ConnectivityApi {}

View File

@@ -1,91 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import '../../../fixtures/asset.stub.dart';
/// Timeline displaying a single asset that we can swap
class _SwappableTimelineService extends TimelineService {
BaseAsset asset;
_SwappableTimelineService(this.asset)
: super((
assetSource: (_, __) async => [],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.main,
));
@override
int get totalAssets => 1;
@override
BaseAsset? getAssetSafe(int index) => index == 0 ? asset : null;
}
class _ZoomedNotifier extends AssetViewerStateNotifier {
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: LocalAssetStub.image1, isZoomed: true);
}
}
void main() {
testWidgets('resets zoom when the displayed asset is replaced on reload', (tester) async {
final timeline = _SwappableTimelineService(LocalAssetStub.image1);
final container = ProviderContainer(
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(_ZoomedNotifier.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: Builder(
builder: (context) => MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: const Material(child: AssetPage(index: 0, heroOffset: 0)),
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 600));
// Ignore any image load errors
tester.takeException();
expect(container.read(assetViewerProvider).isZoomed, isTrue);
// Swap the asset, simulating a delete
timeline.asset = LocalAssetStub.image2;
EventStream.shared.emit(const TimelineReloadEvent());
await tester.idle();
await tester.pump();
tester.takeException();
expect(container.read(assetViewerProvider).isZoomed, isFalse);
});
}

View File

@@ -1,85 +0,0 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import '../../../fixtures/asset.stub.dart';
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: LocalAssetStub.image1);
}
}
TimelineService _stubTimelineService() {
return TimelineService((
assetSource: (index, count) async => [LocalAssetStub.image1],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.main,
));
}
void main() {
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
// Emulate arriving from a light-themed page whose AppBar set dark status
// bar icons (the state the viewer is opened from in light mode).
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
await tester.pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
],
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: const Material(child: AssetViewer(initialIndex: 0)),
),
),
),
),
);
// Let post-frame callbacks and the initial frames run without waiting for
// infinite animations (loading spinners) to settle.
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
// Asset thumbnails cannot load in the test environment (no platform
// channels / real HTTP). Those image errors are irrelevant to the system
// chrome behaviour under test, so drain them.
tester.takeException();
expect(
SystemChrome.latestStyle?.statusBarIconBrightness,
Brightness.light,
reason:
'The asset viewer draws over a black background, so status bar '
'icons must be light regardless of the app theme',
);
});
}

View File

@@ -118,24 +118,8 @@ void main() {
expect(task, isNotNull);
// For live photos, extension should be changed to match the video file
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should not set visibility for a regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'Regular.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse);
});
});
group('getLivePhotoUploadTask', () {
@@ -156,7 +140,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields.containsKey('visibility'), isFalse);
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
@@ -350,7 +334,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields.containsKey('visibility'), isFalse);
expect(task.fields['visibility'], equals('hidden'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));

View File

@@ -1,128 +0,0 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../api.mocks.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() {
late ForegroundUploadService sut;
late MockUploadRepository mockUploadRepository;
late MockStorageRepository mockStorageRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockConnectivityApi mockConnectivityApi;
late MockAssetMediaRepository mockAssetMediaRepository;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SettingsRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app');
await Store.put(StoreKey.deviceId, 'device-id');
registerFallbackValue(File('file'));
registerFallbackValue(<String, String>{});
});
setUp(() {
mockUploadRepository = MockUploadRepository();
mockStorageRepository = MockStorageRepository();
mockBackupRepository = MockDriftBackupRepository();
mockConnectivityApi = MockConnectivityApi();
mockAssetMediaRepository = MockAssetMediaRepository();
sut = ForegroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockBackupRepository,
mockConnectivityApi,
mockAssetMediaRepository,
);
});
List<Map<String, String>> captureFields() {
final captured = <Map<String, String>>[];
when(
() => mockUploadRepository.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
final fields = invocation.namedArguments[#fields] as Map<String, String>;
captured.add(Map.of(fields));
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
});
return captured;
}
group('uploadSingleAsset', () {
test('should upload the motion part hidden and keep the still image visible', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
final captured = captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(captured, hasLength(2));
expect(captured[0]['visibility'], equals('hidden'));
expect(captured[0].containsKey('livePhotoVideoId'), isFalse);
expect(captured[1].containsKey('visibility'), isFalse);
expect(captured[1]['livePhotoVideoId'], equals('remote-1'));
});
test('should not set visibility for a regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/photo.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
final captured = captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(captured, hasLength(1));
expect(captured[0].containsKey('visibility'), isFalse);
});
});
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/mime.utils.dart';
void main() {
group('isUnsupportedMimeError', () {
PlatformException err(Object? details) => PlatformException(code: 'saveImageWithPath', details: details);
test('detects the MediaStore unsupported-mime failure', () {
expect(isUnsupportedMimeError(err('java.lang.IllegalArgumentException: Unsupported MIME type image/*')), isTrue);
});
test('is case-insensitive', () {
expect(isUnsupportedMimeError(err('unsupported mime type foo')), isTrue);
});
test('ignores other Unsupported* errors', () {
expect(isUnsupportedMimeError(err('java.lang.UnsupportedOperationException')), isFalse);
});
test('ignores unrelated failures and non-string details', () {
expect(isUnsupportedMimeError(err('Permission denied')), isFalse);
expect(isUnsupportedMimeError(err(null)), isFalse);
expect(isUnsupportedMimeError(err(42)), isFalse);
});
});
}

View File

@@ -7139,9 +7139,9 @@
"in": "query",
"description": "Filter by date",
"schema": {
"format": "date",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
"example": "2024-01-01",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"example": "2024-01-01T00:00:00.000Z",
"type": "string"
}
},
@@ -7308,9 +7308,9 @@
"in": "query",
"description": "Filter by date",
"schema": {
"format": "date",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
"example": "2024-01-01",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"example": "2024-01-01T00:00:00.000Z",
"type": "string"
}
},
@@ -16223,7 +16223,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.3",
"version": "3.0.2",
"contact": {}
},
"tags": [

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.3",
"version": "3.0.2",
"description": "Monorepo for Immich",
"type": "module",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.3",
"version": "3.0.2",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.3",
"version": "3.0.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.3
* 3.0.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS builder
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202607132058@sha256:85cca651310842265015f26a6710bb7420a54a8fd1ec22d519b7b620f5f93bb1
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS dev
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.3",
"version": "3.0.2",
"description": "",
"author": "",
"private": true,

View File

@@ -4,13 +4,13 @@ import { HistoryBuilder } from 'src/decorators';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
import { isoDatetimeToDate, isoDateToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import z from 'zod';
const MemorySearchSchema = z
.object({
type: MemoryTypeSchema.optional(),
for: isoDateToDate.optional().describe('Filter by date'),
for: isoDatetimeToDate.optional().describe('Filter by date'),
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),

View File

@@ -1,16 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// (#29836) Reset the visibility of any still images that are hidden and have a motion part
await sql`
UPDATE "asset"
SET "visibility" = 'timeline'
WHERE "type" = 'IMAGE'
AND "visibility" = 'hidden'
AND "livePhotoVideoId" IS NOT NULL
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the previous 'hidden' value was itself the bug.
}

View File

@@ -663,7 +663,6 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });

View File

@@ -335,14 +335,6 @@ export class AlbumService extends BaseService {
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, userId, { withAssets: false });
const owner = album.albumUsers[0];
if (owner.user.id === userId) {
throw new BadRequestException('User is owner');
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}

View File

@@ -2232,26 +2232,6 @@ describe(MediaService.name, () => {
);
});
it('should include hevc tag when target is hevc and using hwa', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR10 });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
targetVideoCodec: VideoCodec.Hevc,
accel: TranscodeHardwareAcceleration.Nvenc,
},
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v', 'hevc_nvenc', '-tag:v', 'hvc1']),
twoPass: false,
}),
);
});
it('should copy audio stream when audio matches target', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } });

View File

@@ -118,11 +118,7 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}

View File

@@ -241,8 +241,7 @@ export class BaseConfig implements VideoCodecSWConfig {
options.push('-keyint_min', `${this.getGopSize()}`);
}
}
const isHvc =
this.config.targetVideoCodec === VideoCodec.Hevc && (videoCodec !== 'copy' || videoStream.codecName === 'hevc');
const isHvc = (videoCodec === 'copy' ? videoStream.codecName : videoCodec) === VideoCodec.Hevc;
if (isHvc) {
options.push('-tag:v', 'hvc1');
}

View File

@@ -196,19 +196,4 @@ describe(SearchService.name, () => {
expect(suggestions).toEqual(['Canon', null]);
});
});
describe('searchRandom', () => {
it('should filter out locked assets in a default session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchRandom(auth, {});
expect(response.length).toBe(0);
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "3.0.3",
"version": "3.0.2",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

View File

@@ -7,16 +7,6 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
describe('AssetViewerNavBar component', () => {
const additionalProps = {
preAction: () => {},
@@ -34,6 +24,15 @@ describe('AssetViewerNavBar component', () => {
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
});
afterEach(() => {

View File

@@ -4,14 +4,16 @@ import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });

View File

@@ -4,11 +4,6 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
@@ -31,6 +26,10 @@ vi.hoisted(() => {
describe('Thumbnail component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
});
it('should only contain a single tabbable element (the container)', () => {

View File

@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
// wtf
await tick();

View File

@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
await locale.set('en');
});

View File

@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { toTimelineAsset } from '$lib/utils/timeline-util';
type MemoryIndex = {
@@ -130,7 +131,7 @@ class MemoryManager {
}
private async load() {
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}

View File

@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
it('loads a month', async () => {
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toHaveBeenCalledOnce();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled month', async () => {
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
expect(previousMonthSpy).not.toHaveBeenCalled();
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {

View File

@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
<BasicModal title={$t('add_step')} {onClose} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />

View File

@@ -2,15 +2,17 @@ import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
describe('SharedLinkService', () => {
beforeAll(() => {
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
});
describe('asUrl', () => {
it('should properly encode characters in slug', () => {
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');

View File

@@ -1,10 +1,6 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
@@ -45,6 +41,10 @@ describe('getShortDateRange', () => {
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {

View File

@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
// The last task will be executed after 200ms and will finish at 400ms
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toHaveBeenCalled();
expect(finished).not.toBeCalled();
expect(started).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(100);