Compare commits

..

3 Commits

Author SHA1 Message Date
Alex Tran f7f8d38ad5 feedback 2026-05-15 10:51:24 -05:00
Alex Tran f8244e953b pr feedback 2026-05-14 15:50:49 -05:00
Alex Tran 865df448ef feat: add release candidate flow to bump script and prepare-release workflow 2026-05-13 15:26:13 -05:00
32 changed files with 555 additions and 428 deletions
+2
View File
@@ -116,6 +116,7 @@ jobs:
~/.gradle/wrapper ~/.gradle/wrapper
~/.android/sdk ~/.android/sdk
mobile/android/.gradle mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Android SDK - name: Setup Android SDK
@@ -188,6 +189,7 @@ jobs:
~/.gradle/wrapper ~/.gradle/wrapper
~/.android/sdk ~/.android/sdk
mobile/android/.gradle mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios: build-sign-ios:
+13 -2
View File
@@ -17,6 +17,15 @@ on:
description: 'Bump mobile build number' description: 'Bump mobile build number'
required: false required: false
type: boolean type: boolean
rc:
description: 'Release candidate mode'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
- 'finalize'
skipTranslations: skipTranslations:
description: 'Skip translations' description: 'Skip translations'
required: false required: false
@@ -74,7 +83,8 @@ jobs:
env: env:
SERVER_BUMP: ${{ inputs.serverBump }} SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }} MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" RC: ${{ inputs.rc }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" -r "${RC}"
- id: output - id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
@@ -108,7 +118,7 @@ jobs:
with: with:
ref: ${{ needs.bump_version.outputs.ref }} ref: ${{ needs.bump_version.outputs.ref }}
environment: production environment: ${{ inputs.rc != 'false' && 'rc' || 'production' }}
prepare_release: prepare_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -140,6 +150,7 @@ jobs:
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with: with:
draft: true draft: true
prerelease: ${{ inputs.rc == 'true' }}
tag_name: ${{ needs.bump_version.outputs.version }} tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true generate_release_notes: true
+2 -66
View File
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
## Step 4 - Configure Firewall Settings ## Step 4 - Configure Firewall Settings
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers. Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
@@ -74,7 +74,6 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
<details> <details>
<summary>Updating Immich using Container Manager</summary> <summary>Updating Immich using Container Manager</summary>
Check the post installation and upgrade instructions at the links above before proceeding with this section. Check the post installation and upgrade instructions at the links above before proceeding with this section.
## Step 1. Backup ## Step 1. Backup
@@ -111,7 +110,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
## Step 5. Update firewall rule ## Step 5. Update firewall rule
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png) ![Container IP](../../static/img/synology-container-ip.png)
@@ -124,67 +123,4 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
![Edit IP](../../static/img/synology-fw-ipedit.png) ![Edit IP](../../static/img/synology-fw-ipedit.png)
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
</details>
<details id="set-fixed-subnet">
<summary>Set Fixed Subnet</summary>
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
## Step 1. Determine current subnet
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png)
## Step 2. Add network configuration
Add the following network configuration at the end of your `docker-compose.yml` file:
```yaml
networks:
immich-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
```
If your docker container is running on a different subnet then update accordingly.
## Step 3. Add network to each service
Add the network to each service (immich-server, immich-machine-learning, redis, database):
```yaml
services:
immich-server:
# other config options
networks:
- immich-network
immich-machine-learning:
# other config options
networks:
- immich-network
redis:
# other config options
networks:
- immich-network
database:
# other config options
networks:
- immich-network
```
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
## Step 4. Update Firewall Rules, if necessary
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
</details> </details>
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils'; import { app, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => { describe('/admin/database-backups', () => {
let cookie: string | undefined; let cookie: string | undefined;
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
admin = await utils.adminSetup({ admin = await utils.adminSetup({
onboarding: false, onboarding: false,
}); });
});
beforeEach(async () => {
await utils.resetBackups(admin.accessToken); await utils.resetBackups(admin.accessToken);
}); });
-2
View File
@@ -568,8 +568,6 @@ export const utils = {
name: ManualJobName.BackupDatabase, name: ManualJobName.BackupDatabase,
}); });
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
return utils.poll( return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1, ({ status, body }) => status === 200 && body.backups.length === 1,
+74 -22
View File
@@ -3,21 +3,26 @@
# #
# Pump one or both of the server/mobile versions in appropriate files # Pump one or both of the server/mobile versions in appropriate files
# #
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false> # usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false> <-r> <true|false|finalize>
# #
# examples: # examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50 # ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51 # ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51 # ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
# ./scripts/pump-version.sh -s minor -m true -r true # 3.0.0 => 3.1.0-rc.0 (start RC)
# ./scripts/pump-version.sh -m true -r true # 3.1.0-rc.0 => 3.1.0-rc.1 (iterate RC)
# ./scripts/pump-version.sh -m true -r finalize # 3.1.0-rc.1 => 3.1.0 (finalize RC)
# #
SERVER_PUMP="false" SERVER_PUMP="false"
MOBILE_PUMP="false" MOBILE_PUMP="false"
RC="false"
while getopts 's:m:' flag; do while getopts 's:m:r:' flag; do
case "${flag}" in case "${flag}" in
s) SERVER_PUMP=${OPTARG} ;; s) SERVER_PUMP=${OPTARG} ;;
m) MOBILE_PUMP=${OPTARG} ;; m) MOBILE_PUMP=${OPTARG} ;;
r) RC=${OPTARG} ;;
*) *)
echo "Invalid args" echo "Invalid args"
exit 1 exit 1
@@ -25,28 +30,71 @@ while getopts 's:m:' flag; do
esac esac
done done
CURRENT_SERVER=$(jq -r '.version' server/package.json) if [[ "$RC" != "true" && "$RC" != "false" && "$RC" != "finalize" ]]; then
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1) echo "Expected <true|false|finalize> for the -r argument"
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2) exit 1
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3) fi
if [[ $SERVER_PUMP == "major" ]]; then CURRENT_SERVER=$(jq -r '.version' server/package.json)
MAJOR=$((MAJOR + 1))
MINOR=0 if [[ "$CURRENT_SERVER" == *-rc.* ]]; then
PATCH=0 CURRENT_BASE="${CURRENT_SERVER%-rc.*}"
elif [[ $SERVER_PUMP == "minor" ]]; then CURRENT_RC_NUM="${CURRENT_SERVER##*-rc.}"
MINOR=$((MINOR + 1))
PATCH=0
elif [[ $SERVER_PUMP == "patch" ]]; then
PATCH=$((PATCH + 1))
elif [[ $SERVER_PUMP == "false" ]]; then
echo 'Skipping Server Pump'
else else
CURRENT_BASE="$CURRENT_SERVER"
CURRENT_RC_NUM=""
fi
# Validate RC/server-bump combinations against current version state
if [[ -n "$CURRENT_RC_NUM" ]]; then
# Currently on an RC
if [[ "$RC" == "false" ]]; then
echo "Current version $CURRENT_SERVER is a release candidate. Pass -r true to iterate the RC or -r finalize to finalize the release."
exit 1
fi
if [[ "$RC" == "true" && "$SERVER_PUMP" != "false" ]]; then
echo "Cannot start a new RC while still on an RC; finalize first."
exit 1
fi
if [[ "$RC" == "finalize" && "$SERVER_PUMP" != "false" ]]; then
echo "Finalize takes no server bump."
exit 1
fi
else
# Not currently on an RC
if [[ "$RC" == "true" && "$SERVER_PUMP" == "false" ]]; then
echo "Starting an RC requires a server bump."
exit 1
fi
if [[ "$RC" == "finalize" ]]; then
echo "Nothing to finalize."
exit 1
fi
fi
if [[ "$SERVER_PUMP" != "major" && "$SERVER_PUMP" != "minor" && "$SERVER_PUMP" != "patch" && "$SERVER_PUMP" != "false" ]]; then
echo 'Expected <major|minor|patch|false> for the server argument' echo 'Expected <major|minor|patch|false> for the server argument'
exit 1 exit 1
fi fi
NEXT_SERVER=$MAJOR.$MINOR.$PATCH NEXT_SERVER="$CURRENT_SERVER"
if [[ "$SERVER_PUMP" == "false" && "$RC" == "false" ]]; then
echo 'Skipping Server Pump'
else
npm version "$CURRENT_SERVER" --allow-same-version --no-git-tag-version || exit 1
if [[ "$RC" == "true" && -n "$CURRENT_RC_NUM" ]]; then
npm version prerelease --no-git-tag-version || exit 1
elif [[ "$RC" == "true" ]]; then
npm version "pre$SERVER_PUMP" --preid=rc --no-git-tag-version || exit 1
elif [[ "$RC" == "finalize" ]]; then
npm version "$CURRENT_BASE" --no-git-tag-version || exit 1
else
npm version "$SERVER_PUMP" --no-git-tag-version || exit 1
fi
NEXT_SERVER=$(jq -r '.version' package.json)
fi
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2) CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE NEXT_MOBILE=$CURRENT_MOBILE
@@ -62,7 +110,6 @@ fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER" echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
@@ -72,9 +119,12 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
# copy version to open-api spec # copy version to open-api spec
mise run //:open-api mise run //:open-api
uv version --directory machine-learning "$NEXT_SERVER" NEXT_PY="${NEXT_SERVER//-rc./rc}"
uv version --directory machine-learning "$NEXT_PY"
./misc/release/archive-version.js "$NEXT_SERVER" if [[ "$NEXT_SERVER" != *-rc.* ]]; then
./misc/release/archive-version.js "$NEXT_SERVER"
fi
fi fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
@@ -84,7 +134,9 @@ fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist # iOS marketing version cannot contain a pre-release suffix; the plist always holds the base version.
IOS_NEXT="${NEXT_SERVER%-rc.*}"
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_BASE(<\/string>)/\${1}$IOS_NEXT\${2}/s" mobile/ios/Runner/Info.plist
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV" echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
+300
View File
@@ -0,0 +1,300 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import test from 'node:test';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '../..');
const scriptUnderTest = join(repoRoot, 'misc/release/pump-version.sh');
const read = (path) => readFileSync(path, 'utf8');
const packageVersion = (dir) => JSON.parse(read(join(dir, 'package.json'))).version;
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
const writeExecutable = (path, contents) => {
writeFileSync(path, contents, { mode: 0o755 });
};
const writePackageJson = (dir, name, version) => {
writeFileSync(
join(dir, 'package.json'),
`${JSON.stringify({ name, version, private: true }, null, 2)}\n`,
);
};
const makeFixture = (t, { rootVersion = '2.7.5', serverVersion = '3.0.0', mobileBuild = 3047 } = {}) => {
const workdir = mkdtempSync(join(tmpdir(), 'pump-version-'));
t.after(() => rmSync(workdir, { recursive: true, force: true }));
const currentBase = serverVersion.replace(/-rc\..+$/, '');
for (const path of [
'bin',
'server',
'packages/cli',
'web',
'e2e',
'packages/sdk',
'misc/release',
'mobile/android/fastlane',
'mobile/ios/Runner',
'machine-learning',
]) {
mkdirSync(join(workdir, path), { recursive: true });
}
writeCommandStubs(workdir);
writePackageJson(workdir, 'immich-monorepo', rootVersion);
writePackageJson(join(workdir, 'server'), 'immich', serverVersion);
writePackageJson(join(workdir, 'packages/cli'), '@immich/cli', serverVersion);
writePackageJson(join(workdir, 'web'), 'immich-web', serverVersion);
writePackageJson(join(workdir, 'e2e'), 'immich-e2e', serverVersion);
writePackageJson(join(workdir, 'packages/sdk'), '@immich/sdk', serverVersion);
writeExecutable(
join(workdir, 'misc/release/archive-version.js'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/archive-version.calls"
`,
);
writeFileSync(
join(workdir, 'mobile/pubspec.yaml'),
`name: immich_mobile
version: ${serverVersion}+${mobileBuild}
`,
);
writeFileSync(
join(workdir, 'mobile/android/fastlane/Fastfile'),
`lane :gha_release_prod do
gradle(
properties: {
"android.injected.version.code" => ${mobileBuild},
"android.injected.version.name" => "${serverVersion}",
}
)
end
`,
);
writeFileSync(
join(workdir, 'mobile/ios/Runner/Info.plist'),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>${currentBase}</string>
</dict>
</plist>
`,
);
return {
path: workdir,
file: (path) => join(workdir, path),
readFile: (path) => read(join(workdir, path)),
hasFile: (path) => {
try {
return read(join(workdir, path)).length > 0;
} catch {
return false;
}
},
run: (...args) =>
spawnSync('bash', [scriptUnderTest, ...args], {
cwd: workdir,
env: {
...process.env,
GITHUB_ENV: join(workdir, 'github_env'),
PATH: `${join(workdir, 'bin')}:${process.env.PATH}`,
},
encoding: 'utf8',
}),
};
};
const writeCommandStubs = (workdir) => {
const realNpm = spawnSync('which', ['npm'], { encoding: 'utf8' }).stdout.trim();
writeExecutable(
join(workdir, 'bin/npm'),
`#!/usr/bin/env bash
set -euo pipefail
real_npm=${shellQuote(realNpm)}
echo "$*" >>"$PWD/npm.calls"
"$real_npm" "$@"
`,
);
writeExecutable(
join(workdir, 'bin/pnpm'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" != "version" ]]; then
echo "Unexpected pnpm command: $*" >&2
exit 1
fi
shift
version="\${1:-}"
shift
prefix="."
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix)
prefix="$2"
shift 2
;;
--no-git-tag-version)
shift
;;
*)
echo "Unexpected pnpm argument: $1" >&2
exit 1
;;
esac
done
npm --prefix "$prefix" version "$version" --no-git-tag-version --allow-same-version >/dev/null
`,
);
writeExecutable(
join(workdir, 'bin/mise'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/mise.calls"
`,
);
writeExecutable(
join(workdir, 'bin/uv'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/uv.calls"
if [[ "\${1:-}" != "version" ]]; then
echo "Unexpected uv command: $*" >&2
exit 1
fi
shift
directory="."
if [[ "\${1:-}" == "--directory" ]]; then
directory="$2"
shift 2
fi
version="\${1:-}"
mkdir -p "$directory"
cat >"$directory/pyproject.toml" <<PYPROJECT
[project]
version = "$version"
PYPROJECT
`,
);
};
const assertCommandPassed = (result) => {
assert.equal(result.status, 0, result.stderr || result.stdout);
};
const assertPackageVersions = (fixture, expected) => {
assert.equal(packageVersion(fixture.path), expected);
assert.equal(packageVersion(fixture.file('server')), expected);
assert.equal(packageVersion(fixture.file('packages/cli')), expected);
assert.equal(packageVersion(fixture.file('web')), expected);
assert.equal(packageVersion(fixture.file('e2e')), expected);
assert.equal(packageVersion(fixture.file('packages/sdk')), expected);
};
const npmCalls = (fixture) => fixture.readFile('npm.calls').trim().split('\n');
test('starts an RC from the server version when the root package is stale', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.0.0', mobileBuild: 3047 });
const result = fixture.run('-s', 'minor', '-m', 'true', '-r', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0-rc.0');
assert.ok(npmCalls(fixture).includes('version preminor --preid=rc --no-git-tag-version'));
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.0\+3048/);
assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.name" => "3\.1\.0-rc\.0"/);
assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.code" => 3048/);
assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /<string>3\.1\.0<\/string>/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc0/);
assert.equal(fixture.hasFile('archive-version.calls'), false);
assert.match(fixture.readFile('github_env'), /IMMICH_VERSION=v3\.1\.0-rc\.0/);
});
test('iterates an existing RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 });
const result = fixture.run('-m', 'false', '-r', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0-rc.1');
assert.ok(npmCalls(fixture).includes('version prerelease --no-git-tag-version'));
assert.equal(npmCalls(fixture).some((call) => call.startsWith('version prerelease --preid')), false);
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.1\+3048/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc1/);
assert.equal(fixture.hasFile('archive-version.calls'), false);
});
test('finalizes an existing RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.1', mobileBuild: 3048 });
const result = fixture.run('-m', 'false', '-r', 'finalize');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3048/);
assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /<string>3\.1\.0<\/string>/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0/);
assert.match(fixture.readFile('archive-version.calls'), /3\.1\.0/);
});
test('bumps a normal patch release', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 });
const result = fixture.run('-s', 'patch', '-m', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.1');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.1\+3049/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.1/);
assert.match(fixture.readFile('archive-version.calls'), /3\.1\.1/);
});
test('bumps mobile only', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 });
const result = fixture.run('-m', 'true');
assertCommandPassed(result);
assert.equal(packageVersion(fixture.path), '2.7.5');
assert.equal(packageVersion(fixture.file('server')), '3.1.0');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3049/);
assert.equal(fixture.hasFile('uv.calls'), false);
assert.equal(fixture.hasFile('archive-version.calls'), false);
});
test('rejects starting a new RC while already on an RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 });
const result = fixture.run('-s', 'patch', '-r', 'true');
assert.notEqual(result.status, 0);
assert.match(result.stdout, /Cannot start a new RC while still on an RC; finalize first\./);
assert.equal(packageVersion(fixture.path), '2.7.5');
assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.0');
});
@@ -11,7 +11,6 @@ class RemoteAsset extends BaseAsset {
final String ownerId; final String ownerId;
final String? stackId; final String? stackId;
final DateTime? uploadedAt; final DateTime? uploadedAt;
final DateTime? deletedAt;
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited, required super.isEdited,
this.deletedAt,
}) : localAssetId = localId; }) : localAssetId = localId;
@override @override
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
@override @override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@override @override
String toString() { String toString() {
return '''Asset { return '''Asset {
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
stackId == other.stackId && stackId == other.stackId &&
uploadedAt == other.uploadedAt && uploadedAt == other.uploadedAt;
deletedAt == other.deletedAt;
} }
@override @override
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
stackId.hashCode ^ stackId.hashCode ^
uploadedAt.hashCode ^ uploadedAt.hashCode;
deletedAt.hashCode;
RemoteAsset copyWith({ RemoteAsset copyWith({
String? id, String? id,
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId, String? stackId,
bool? isEdited, bool? isEdited,
DateTime? deletedAt,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited, isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
} }
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
required super.createdAt, required super.createdAt,
required super.updatedAt, required super.updatedAt,
super.uploadedAt, super.uploadedAt,
super.deletedAt,
super.width, super.width,
super.height, super.height,
super.durationMs, super.durationMs,
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt, DateTime? uploadedAt,
DateTime? deletedAt,
int? width, int? width,
int? height, int? height,
int? durationMs, int? durationMs,
@@ -224,7 +214,6 @@ class RemoteAssetExif extends RemoteAsset {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt, uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width, width: width ?? this.width,
height: height ?? this.height, height: height ?? this.height,
durationMs: durationMs ?? this.durationMs, durationMs: durationMs ?? this.durationMs,
+11
View File
@@ -4,15 +4,25 @@ import 'package:immich_mobile/domain/models/user.model.dart';
/// Defines the data type for each value /// Defines the data type for each value
enum StoreKey<T> { enum StoreKey<T> {
version<int>._(0), version<int>._(0),
assetETag<String>._(1),
currentUser<UserDto>._(2), currentUser<UserDto>._(2),
deviceIdHash<int>._(3),
deviceId<String>._(4), deviceId<String>._(4),
backupFailedSince<DateTime>._(5),
backupRequireWifi<bool>._(6),
backupRequireCharging<bool>._(7), backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8), backupTriggerDelay<int>._(8),
serverUrl<String>._(10), serverUrl<String>._(10),
accessToken<String>._(11), accessToken<String>._(11),
serverEndpoint<String>._(12), serverEndpoint<String>._(12),
autoBackup<bool>._(13),
backgroundBackup<bool>._(14),
sslClientCertData<String>._(15),
sslClientPasswd<String>._(16),
uploadErrorNotificationGracePeriod<int>._(106),
selectedAlbumSortOrder<int>._(113), selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114), advancedTroubleshooting<bool>._(114),
selfSignedCert<bool>._(120),
selectedAlbumSortReverse<bool>._(123), selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126), enableHapticFeedback<bool>._(126),
customHeaders<String>._(127), customHeaders<String>._(127),
@@ -28,6 +38,7 @@ enum StoreKey<T> {
// Read-only Mode settings // Read-only Mode settings
readonlyModeEnabled<bool>._(138), readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140), albumGridView<bool>._(140),
loadOriginal<bool>._(101),
// Experimental stuff // Experimental stuff
enableBackup<bool>._(1003), enableBackup<bool>._(1003),
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
localId: localId, localId: localId,
stackId: stackId, stackId: stackId,
isEdited: isEdited, isEdited: isEdited,
deletedAt: deletedAt,
); );
} }
@@ -1,8 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
@@ -32,10 +31,6 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
@@ -65,9 +60,8 @@ import 'package:sqlite_async/sqlite_async.dart';
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
class Drift extends $Drift { class Drift extends $Drift {
Drift(super.executor); Drift([QueryExecutor? executor])
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
Future<void> reset() async { Future<void> reset() async {
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94 // https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
@@ -311,18 +305,3 @@ class DriftDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback); Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
} }
Future<SqliteConnection> openSqliteConnection({required String name}) async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, '$name.sqlite'));
return SqliteDatabase(path: file.path);
}
Future<void> configureSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sand-boxing.
final cacheBase = (await getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cacheBase;
}
@@ -1,14 +1,14 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity]) @DriftDatabase(tables: [LogMessageEntity])
class DriftLogger extends $DriftLogger { class DriftLogger extends $DriftLogger {
DriftLogger.fromExecutor(super.executor); DriftLogger([QueryExecutor? executor])
: super(
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db)); executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
);
@override @override
int get schemaVersion => 1; int get schemaVersion => 1;
@@ -19,8 +19,7 @@ class DriftLogger extends $DriftLogger {
await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA synchronous = NORMAL');
await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA journal_mode = WAL');
await customStatement('PRAGMA busy_timeout = 30000'); // 30s await customStatement('PRAGMA busy_timeout = 500');
await customStatement('PRAGMA cache_size = -32000'); // 32MB
await customStatement('PRAGMA temp_store = MEMORY'); await customStatement('PRAGMA temp_store = MEMORY');
}, },
); );
@@ -35,11 +35,10 @@ class BaseActionButton extends ConsumerWidget {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context); final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0; final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color; final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) { if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton( return IconButton(
onPressed: onPressed, onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor), icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -47,18 +46,17 @@ class BaseActionButton extends ConsumerWidget {
} }
if (menuItem) { if (menuItem) {
final iconColor = this.iconColor; final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton( return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: iconColor), leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed, onPressed: onPressed,
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)), child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
); );
} }
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton( child: MaterialButton(
@@ -18,15 +18,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool iconOnly; final bool iconOnly;
final bool menuItem; final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({ const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async { void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
return BaseActionButton( return BaseActionButton(
maxWidth: 110.0, maxWidth: 110.0,
iconData: Icons.delete_forever, iconData: Icons.delete_forever,
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context), label: "delete_permanently".t(context: context),
iconOnly: iconOnly, iconOnly: iconOnly,
menuItem: menuItem, menuItem: menuItem,
onPressed: () => _onTap(context, ref), onPressed: () => _onTap(context, ref),
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.history_rounded,
label: 'restore'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100.0,
);
}
}
@@ -2,19 +2,15 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -37,31 +33,23 @@ class ViewerBottomBar extends ConsumerWidget {
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider); final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData; final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote) const ShareActionButton(source: ActionSource.viewer),
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[ if (!isInLockedView) ...[
if (!isInTrash) ...[ if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), // edit sync was added in 2.6.0
// edit sync was added in 2.6.0 if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) const EditImageActionButton(),
const EditImageActionButton(), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[ if (isOwner) ...[
if (asset.isLocalOnly) asset.isLocalOnly
const DeleteLocalActionButton(source: ActionSource.viewer) ? const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed) : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
], ],
], ],
]; ];
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin, timelineOrigin: timelineOrigin,
); );
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
return MenuAnchor( return MenuAnchor(
consumeOutsideTap: true, consumeOutsideTap: true,
+3 -1
View File
@@ -11,11 +11,12 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -143,6 +144,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Due to the flow of the code, this will always happen on first login // Due to the flow of the code, this will always happen on first login
user = serverUser; user = serverUser;
await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
} }
} on ApiException catch (error, stackTrace) { } on ApiException catch (error, stackTrace) {
if (error.code == 401) { if (error.code == 401) {
@@ -2,9 +2,15 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> { enum AppSettingsEnum<T> {
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false), manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true), selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false), syncAlbums<bool>(StoreKey.syncAlbums, null, false),
+1
View File
@@ -123,6 +123,7 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching), Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint), Store.delete(StoreKey.localEndpoint),
+6 -21
View File
@@ -21,7 +21,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -82,7 +81,6 @@ enum ActionButtonType {
moveToLockFolder, moveToLockFolder,
removeFromLockFolder, removeFromLockFolder,
removeFromAlbum, removeFromAlbum,
restoreTrash,
trash, trash,
deleteLocal, deleteLocal,
deletePermanent, deletePermanent,
@@ -114,17 +112,12 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote && // context.asset.hasRemote && //
context.isTrashEnabled && // context.isTrashEnabled,
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent => ActionButtonType.deletePermanent =>
context.isOwner && // context.isOwner && //
context.asset.hasRemote && // context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), !context.isTrashEnabled ||
context.isInLockedView,
ActionButtonType.delete => ActionButtonType.delete =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
@@ -208,11 +201,6 @@ enum ActionButtonType {
), ),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deletePermanent => DeletePermanentActionButton( ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source, source: context.source,
iconOnly: iconOnly, iconOnly: iconOnly,
@@ -304,7 +292,6 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder => 10, ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10, ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10, ActionButtonType.delete => 10,
ActionButtonType.restoreTrash => 10,
// 90: advancedInfo // 90: advancedInfo
ActionButtonType.advancedInfo => 90, ActionButtonType.advancedInfo => 90,
// 1: others // 1: others
@@ -322,15 +309,13 @@ class ActionButtonBuilder {
ActionButtonType.delete, ActionButtonType.delete,
ActionButtonType.archive, ActionButtonType.archive,
ActionButtonType.unarchive, ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
}; };
static List<Widget> build(ActionButtonContext context) { static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
} }
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
final visibleButtons = defaultViewerKebabMenuOrder final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList(); .toList();
@@ -346,7 +331,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) { if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1)); result.add(const Divider(height: 1));
} }
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); result.add(type.buildButton(context, buildContext, false, true));
lastGroup = type.kebabMenuGroup; lastGroup = type.kebabMenuGroup;
} }
+2 -3
View File
@@ -43,9 +43,8 @@ void configureFileDownloaderNotifications() {
abstract final class Bootstrap { abstract final class Bootstrap {
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async { static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
await configureSqliteCache(); final drift = Drift();
final drift = Drift.sqlite(await openSqliteConnection(name: 'immich')); final logDb = DriftLogger();
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
final DriftStoreRepository storeRepo = DriftStoreRepository(drift); final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
@@ -3,7 +3,10 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -13,6 +16,9 @@ class NotificationSetting extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider); final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final hasPermission = permissionService == PermissionStatus.granted; final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) { openAppNotificationSettings(BuildContext ctx) {
@@ -35,6 +41,8 @@ class NotificationSetting extends HookConsumerWidget {
); );
} }
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [ final notificationSettings = [
if (!hasPermission) if (!hasPermission)
SettingsButtonListTile( SettingsButtonListTile(
@@ -49,8 +57,32 @@ class NotificationSetting extends HookConsumerWidget {
} }
}), }),
), ),
SettingsSliderListTile(
enabled: hasPermission,
valueNotifier: sliderValue,
text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}),
maxValue: 5.0,
noDivisons: 5,
label: formattedValue,
),
]; ];
return SettingsSubPageScaffold(settings: notificationSettings); return SettingsSubPageScaffold(settings: notificationSettings);
} }
} }
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'});
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'});
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'});
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'});
} else {
return 'setting_notifications_notify_never'.tr();
}
}
+16 -24
View File
@@ -370,11 +370,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.32.1" version: "2.32.1"
drift_sqlite_async: drift_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift_sqlite_async name: drift_flutter
sha256: "1b6e99562fc5d35fe5e3696741720a8aca47f4c3eee35d4b9b94be819f53a6f6" sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.0" version: "0.3.0"
@@ -1619,38 +1619,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
sqlcipher_flutter_libs:
dependency: transitive
description:
name: sqlcipher_flutter_libs
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
url: "https://pub.dev"
source: hosted
version: "0.7.0+eol"
sqlite3: sqlite3:
dependency: "direct main" dependency: transitive
description: description:
name: sqlite3 name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.3.1"
sqlite3_connection_pool: sqlite3_flutter_libs:
dependency: transitive dependency: transitive
description: description:
name: sqlite3_connection_pool name: sqlite3_flutter_libs
sha256: "90b25972c7699d84da97df1c5919804275560b4ab8a158bbec890434b9718f65" sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.4" version: "0.6.0+eol"
sqlite3_web:
dependency: transitive
description:
name: sqlite3_web
sha256: d876398a9f2cbf115d93fc34901f8fa129b58b13b5fa9377156ed3a9a05695e3
url: "https://pub.dev"
source: hosted
version: "0.7.1"
sqlite_async:
dependency: "direct main"
description:
name: sqlite_async
sha256: "4c243c5386eba3a7102f98999388a7e0a7f2632e4e06dafb3b4f5a44170a26f6"
url: "https://pub.dev"
source: hosted
version: "0.14.1"
sqlparser: sqlparser:
dependency: transitive dependency: transitive
description: description:
+1 -3
View File
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7 crypto: ^3.0.7
device_info_plus: ^12.4.0 device_info_plus: ^12.4.0
drift: ^2.32.1 drift: ^2.32.1
drift_sqlite_async: 0.3.0 drift_flutter: ^0.3.0
dynamic_color: ^1.8.1 dynamic_color: ^1.8.1
easy_localization: ^3.0.8 easy_localization: ^3.0.8
ffi: ^2.2.0 ffi: ^2.2.0
@@ -66,8 +66,6 @@ dependencies:
share_plus: ^10.1.4 share_plus: ^10.1.4
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
stream_transform: ^2.1.1 stream_transform: ^2.1.1
sqlite3: ^3.3.1
sqlite_async: 0.14.1
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
url_launcher: ^6.3.2 url_launcher: ^6.3.2
@@ -131,7 +131,7 @@ void main() {
durationMs: 0, durationMs: 0,
orientation: 0, orientation: 0,
isFavorite: false, isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image, playbackStyle: PlatformAssetPlaybackStyle.image
); );
final assetsToRestore = [LocalAssetStub.image1]; final assetsToRestore = [LocalAssetStub.image1];
@@ -215,7 +215,7 @@ void main() {
isFavorite: false, isFavorite: false,
createdAt: 1700000000, createdAt: 1700000000,
updatedAt: 1732000000, updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image, playbackStyle: PlatformAssetPlaybackStyle.image
); );
final localAsset = platformAsset.toLocalAsset(); final localAsset = platformAsset.toLocalAsset();
@@ -9,8 +9,9 @@ import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
const _kAccessToken = '#ThisIsAToken'; const _kAccessToken = '#ThisIsAToken';
const _kEnableBackup = false; const _kBackgroundBackup = false;
const _kVersion = 2; const _kVersion = 2;
final _kBackupFailedSince = DateTime.utc(2023);
void main() { void main() {
late StoreService sut; late StoreService sut;
@@ -23,13 +24,15 @@ void main() {
// For generics, we need to provide fallback to each concrete type to avoid runtime errors // For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.backupTriggerDelay); registerFallbackValue(StoreKey.backupTriggerDelay);
registerFallbackValue(StoreKey.enableBackup); registerFallbackValue(StoreKey.backgroundBackup);
registerFallbackValue(StoreKey.backupFailedSince);
when(() => mockDriftStoreRepo.getAll()).thenAnswer( when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [ (_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken), const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.enableBackup, _kEnableBackup), const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
const StoreDto(StoreKey.version, _kVersion), const StoreDto(StoreKey.version, _kVersion),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
], ],
); );
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -46,8 +49,9 @@ void main() {
test('Populates the internal cache on init', () { test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1); verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup); expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
expect(sut.tryGet(StoreKey.version), _kVersion); expect(sut.tryGet(StoreKey.version), _kVersion);
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
// Other keys should be null // Other keys should be null
expect(sut.tryGet(StoreKey.currentUser), isNull); expect(sut.tryGet(StoreKey.currentUser), isNull);
}); });
@@ -147,8 +151,9 @@ void main() {
await sut.clear(); await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1); verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), isNull); expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.enableBackup), isNull); expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
expect(sut.tryGet(StoreKey.version), isNull); expect(sut.tryGet(StoreKey.version), isNull);
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
}); });
}); });
} }
@@ -12,8 +12,9 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import '../../fixtures/user.stub.dart'; import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken"; const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10; const _kTestVersion = 10;
const _kTestBackupRequireCharging = false; const _kTestBackupRequireWifi = false;
final _kTestUser = UserStub.admin; final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async { Future<void> _populateStore(Drift db) async {
@@ -21,8 +22,16 @@ Future<void> _populateStore(Drift db) async {
batch.insert( batch.insert(
db.storeEntity, db.storeEntity,
StoreEntityCompanion( StoreEntityCompanion(
id: Value(StoreKey.backupRequireCharging.id), id: Value(StoreKey.backupRequireWifi.id),
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0), intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
stringValue: const Value(null), stringValue: const Value(null),
), ),
); );
@@ -75,12 +84,20 @@ void main() {
expect(accessToken, _kTestAccessToken); expect(accessToken, _kTestAccessToken);
}); });
test('converts datetime', () async {
DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, isNull);
await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed);
backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, _kTestBackupFailed);
});
test('converts bool', () async { test('converts bool', () async {
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireCharging, isNull); expect(backupRequireWifi, isNull);
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging); await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireCharging, _kTestBackupRequireCharging); expect(backupRequireWifi, _kTestBackupRequireWifi);
}); });
test('converts user', () async { test('converts user', () async {
@@ -98,11 +115,11 @@ void main() {
}); });
test('delete()', () async { test('delete()', () async {
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireCharging, isFalse); expect(backupRequireWifi, isFalse);
await sut.delete(StoreKey.backupRequireCharging); await sut.delete(StoreKey.backupRequireWifi);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireCharging, isNull); expect(backupRequireWifi, isNull);
}); });
test('deleteAll()', () async { test('deleteAll()', () async {
@@ -147,12 +164,14 @@ void main() {
emitsInOrder([ emitsInOrder([
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion), const StoreDto<Object>(StoreKey.version, _kTestVersion),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
], ],
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10), const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
], ],
]), ]),
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({ LocalAsset createLocalAsset({
@@ -38,7 +37,6 @@ RemoteAsset createRemoteAsset({
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt, DateTime? uploadedAt,
bool isFavorite = false, bool isFavorite = false,
DateTime? deletedAt,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: 'remote-id', id: 'remote-id',
@@ -52,7 +50,6 @@ RemoteAsset createRemoteAsset({
uploadedAt: uploadedAt ?? DateTime.now(), uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite, isFavorite: isFavorite,
isEdited: false, isEdited: false,
deletedAt: deletedAt,
); );
} }
@@ -461,62 +458,6 @@ void main() {
expect(ActionButtonType.trash.shouldShow(context), isFalse); expect(ActionButtonType.trash.shouldShow(context), isFalse);
}); });
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
});
test('should not show when not in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.main,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
});
}); });
group('deletePermanent button', () { group('deletePermanent button', () {
@@ -553,24 +494,6 @@ void main() {
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
}); });
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
}); });
group('delete button', () { group('delete button', () {
+4 -7
View File
@@ -171,8 +171,8 @@ export class JobRepository {
options: this.getJobOptions(item) || undefined, options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined }; } as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId || job.options?.deduplication) { if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId/deduplication to take effect // need to use add() instead of addBulk() for jobId deduplication
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else { } else {
itemsByQueue[queueName] = itemsByQueue[queueName] || []; itemsByQueue[queueName] = itemsByQueue[queueName] || [];
@@ -230,13 +230,10 @@ export class JobRepository {
return { priority: 1 }; return { priority: 1 };
} }
case JobName.FacialRecognitionQueueAll: { case JobName.FacialRecognitionQueueAll: {
return { deduplication: { id: JobName.FacialRecognitionQueueAll } }; return { jobId: JobName.FacialRecognitionQueueAll };
} }
case JobName.VersionCheck: { case JobName.VersionCheck: {
return { deduplication: { id: JobName.VersionCheck } }; return { jobId: JobName.VersionCheck };
}
case JobName.DatabaseBackup: {
return { deduplication: { id: JobName.DatabaseBackup } };
} }
default: { default: {
return null; return null;
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
private async check(url: string) { private async check(url: string) {
let healthy = false; let healthy = false;
try { try {
const response = await fetch(new URL('ping', url), { const response = await fetch(new URL('/ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
}); });
if (response.ok) { if (response.ok) {
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
...this.config.urls.filter((url) => !this.isHealthy(url)), ...this.config.urls.filter((url) => !this.isHealthy(url)),
]) { ]) {
try { try {
const response = await fetch(new URL('predict', url), { method: 'POST', body: formData }); const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) { if (response.ok) {
this.setHealthy(url, true); this.setHealthy(url, true);
return response.json(); return response.json();
+4 -33
View File
@@ -4,18 +4,15 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte'; import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js'; import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -43,15 +40,6 @@
} }
} }
}; };
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script> </script>
<OnEvents {onPersonThumbnailReady} /> <OnEvents {onPersonThumbnailReady} />
@@ -134,20 +122,15 @@
draggable="false">{$t('view_all')}</a draggable="false">{$t('view_all')}</a
> >
</div> </div>
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42"> <div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)} {#each recents as item (item.data.id)}
<button <a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<img <img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })} src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))} alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover" class="size-full min-w-max rounded-xl object-cover"
/> />
</button> </a>
{/each} {/each}
</div> </div>
</div> </div>
@@ -157,15 +140,3 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" /> <EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if} {/if}
</UserPageLayout> </UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}