mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af8bf90dd9 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different ."
|
"format:fix": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4"
|
"prettier": "^3.7.4"
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
|
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||||
|
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Check PR Template
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types: [opened, edited]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
parse:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github/pull_request_template.md
|
|
||||||
sparse-checkout-cone-mode: false
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check required sections
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
BODY: ${{ github.event.pull_request.body }}
|
|
||||||
run: |
|
|
||||||
OK=true
|
|
||||||
while IFS= read -r header; do
|
|
||||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
|
||||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
|
||||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
act:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: parse
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Close PR
|
|
||||||
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
|
||||||
-f query='
|
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
|
||||||
addComment(input: {
|
|
||||||
subjectId: $prId,
|
|
||||||
body: $body
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
closePullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
|
|
||||||
- name: Reopen PR (sections now present, PR closed)
|
|
||||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f query='
|
|
||||||
mutation ReopenPR($prId: ID!) {
|
|
||||||
reopenPullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
- device: rocm
|
- device: rocm
|
||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './docs/.nvmrc'
|
node-version-file: './docs/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ jobs:
|
|||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Manage release PR
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
with:
|
||||||
|
node-version-file: './server/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Determine release type
|
||||||
|
id: bump-type
|
||||||
|
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Bump versions
|
||||||
|
env:
|
||||||
|
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||||
|
run: |
|
||||||
|
if [ "$TYPE" == "none" ]; then
|
||||||
|
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||||
|
fi
|
||||||
|
misc/release/pump-version.sh -s $TYPE -m true
|
||||||
|
|
||||||
|
- name: Manage Outline release document
|
||||||
|
id: outline
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||||
|
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
let documentId;
|
||||||
|
let documentUrl;
|
||||||
|
let documentText;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
// Create new document
|
||||||
|
console.log('No existing document found. Creating new one...');
|
||||||
|
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'next',
|
||||||
|
text: notesTmpl,
|
||||||
|
collectionId: collectionId,
|
||||||
|
parentDocumentId: parentDocumentId,
|
||||||
|
publish: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData = await createResponse.json();
|
||||||
|
documentId = createData.data.id;
|
||||||
|
const urlId = createData.data.urlId;
|
||||||
|
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||||
|
documentText = createData.data.text || '';
|
||||||
|
console.log(`Created new document: ${documentUrl}`);
|
||||||
|
} else {
|
||||||
|
documentId = document.id;
|
||||||
|
const docPath = document.url;
|
||||||
|
documentUrl = `${baseUrl}${docPath}`;
|
||||||
|
documentText = document.text || '';
|
||||||
|
console.log(`Found existing document: ${documentUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GitHub release notes
|
||||||
|
console.log('Generating GitHub release notes...');
|
||||||
|
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
tag_name: `${process.env.NEXT_VERSION}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the content
|
||||||
|
const changelog = `
|
||||||
|
# ${process.env.NEXT_VERSION}
|
||||||
|
|
||||||
|
${documentText}
|
||||||
|
|
||||||
|
${releaseNotesResponse.data.body}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||||
|
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||||
|
|
||||||
|
core.setOutput('document_url', documentUrl);
|
||||||
|
|
||||||
|
- name: Create PR
|
||||||
|
id: create-pr
|
||||||
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||||
|
labels: 'changelog:skip'
|
||||||
|
branch: 'release/next'
|
||||||
|
draft: true
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: release.yml
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
paths:
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Maybe double check PR source branch?
|
||||||
|
|
||||||
|
merge_translations:
|
||||||
|
uses: ./.github/workflows/merge-translations.yml
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
secrets:
|
||||||
|
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||||
|
|
||||||
|
build_mobile:
|
||||||
|
uses: ./.github/workflows/build-mobile.yml
|
||||||
|
needs: merge_translations
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
secrets:
|
||||||
|
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||||
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
prepare_release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build_mobile
|
||||||
|
permissions:
|
||||||
|
actions: read # To download the app artifact
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: false
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||||
|
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||||
|
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||||
|
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download APK
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.result }}
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
body_path: ${{ steps.changelog.outputs.path }}
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
docker/docker-compose.yml
|
||||||
|
docker/docker-compose.rootless.yml
|
||||||
|
docker/example.env
|
||||||
|
docker/hwaccel.ml.yml
|
||||||
|
docker/hwaccel.transcoding.yml
|
||||||
|
docker/prometheus.yml
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
- name: Rename Outline document
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
VERSION: ${{ steps.changelog.outputs.version }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||||
|
|
||||||
|
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: document.id,
|
||||||
|
title: version
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No document titled "next" found to rename');
|
||||||
|
}
|
||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
+27
-27
@@ -75,9 +75,9 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -119,9 +119,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -166,9 +166,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -208,9 +208,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -252,9 +252,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -290,9 +290,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -338,9 +338,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -385,9 +385,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -424,9 +424,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -496,9 +496,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -620,7 +620,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -661,9 +661,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './.github/.nvmrc'
|
node-version-file: './.github/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -712,9 +712,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -774,9 +774,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
Vendored
+1
-8
@@ -5,13 +5,6 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"dart-code.flutter",
|
"dart-code.flutter",
|
||||||
"dart-code.dart-code",
|
"dart-code.dart-code",
|
||||||
"dcmdev.dcm-vscode-extension",
|
"dcmdev.dcm-vscode-extension"
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"ms-playwright.playwright",
|
|
||||||
"vitest.explorer",
|
|
||||||
"editorconfig.editorconfig",
|
|
||||||
"foxundermoon.shell-format",
|
|
||||||
"timonwong.shellcheck",
|
|
||||||
"bluebrown.yamlfmt"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+13
-35
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[dart]": {
|
"[dart]": {
|
||||||
"editor.defaultFormatter": "Dart-Code.dart-code",
|
"editor.defaultFormatter": "Dart-Code.dart-code",
|
||||||
@@ -18,15 +19,18 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[svelte]": {
|
"[svelte]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -34,7 +38,8 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -42,45 +47,18 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
"css.lint.unknownAtRules": "ignore",
|
|
||||||
"editor.bracketPairColorization.enabled": true,
|
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.useFlatConfig": true,
|
|
||||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
"eslint.workingDirectories": [
|
|
||||||
{ "directory": "cli", "changeProcessCWD": true },
|
|
||||||
{ "directory": "e2e", "changeProcessCWD": true },
|
|
||||||
{ "directory": "server", "changeProcessCWD": true },
|
|
||||||
{ "directory": "web", "changeProcessCWD": true }
|
|
||||||
],
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.jj/**": true,
|
|
||||||
"**/.git/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true,
|
|
||||||
"**/.svelte-kit/**": true
|
|
||||||
},
|
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true,
|
|
||||||
"**/build": true,
|
|
||||||
"**/dist": true,
|
|
||||||
"**/.svelte-kit": true,
|
|
||||||
"**/open-api/typescript-sdk/src": true
|
|
||||||
},
|
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"tailwindCSS.experimental.configFile": {
|
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||||
"web/src/app.css": "web/src/**"
|
|
||||||
},
|
|
||||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
|
||||||
"vitest.maximumConfigs": 10
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
|
|||||||
|
|
||||||
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||||
|
|
||||||
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
|
||||||
|
|
||||||
## Use of generative AI
|
## Use of generative AI
|
||||||
|
|
||||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||||
|
|||||||
+5
-5
@@ -20,8 +20,8 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.11.0",
|
"@types/node": "^24.10.13",
|
||||||
"@vitest/coverage-v8": "^4.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-tsconfig-paths": "^6.0.0",
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^3.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
"prepack": "pnpm run build",
|
"prepack": "pnpm run build",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:cov": "vitest --coverage",
|
"test:cov": "vitest --coverage",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file is successful', async () => {
|
it('returns new assets when upload file is successful', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
||||||
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
|
|||||||
|
|
||||||
it('returns new assets when upload file retry is successful', async () => {
|
it('returns new assets when upload file retry is successful', async () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
counter++;
|
counter++;
|
||||||
if (counter < retry) {
|
if (counter < retry) {
|
||||||
throw new Error('Network error');
|
throw new Error('Network error');
|
||||||
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file retry is failed', async () => {
|
it('returns new assets when upload file retry is failed', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
throw new Error('Network error');
|
throw new Error('Network error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,8 +236,7 @@ describe('startWatch', () => {
|
|||||||
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
assets: [
|
assets: [
|
||||||
@@ -246,9 +245,7 @@ describe('startWatch', () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out unsupported files', async () => {
|
it('should filter out unsupported files', async () => {
|
||||||
@@ -260,8 +257,7 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
assets: expect.arrayContaining([
|
assets: expect.arrayContaining([
|
||||||
@@ -270,9 +266,7 @@ describe('startWatch', () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
@@ -297,8 +291,7 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
assets: expect.arrayContaining([
|
assets: expect.arrayContaining([
|
||||||
@@ -307,9 +300,7 @@ describe('startWatch', () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
|
|||||||
+2
-6
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig, UserConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -17,8 +17,4 @@ export default defineConfig({
|
|||||||
noExternal: /^(?!node:).*$/,
|
noExternal: /^(?!node:).*$/,
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [tsconfigPaths()],
|
||||||
test: {
|
});
|
||||||
name: 'cli:unit',
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
} as UserConfig);
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.99.4"
|
terragrunt = "0.98.0"
|
||||||
opentofu = "1.11.5"
|
opentofu = "1.11.4"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ graph TD
|
|||||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||||
D --> E[Smart Search]
|
D --> E[Smart Search]
|
||||||
D --> F[Face Detection]
|
D --> F[Face Detection]
|
||||||
D --> G[OCR]
|
D --> G[Video Transcoding]
|
||||||
D --> H[Video Transcoding]
|
E --> H[Duplicate Detection]
|
||||||
E --> I[Duplicate Detection]
|
F --> I[Facial Recognition]
|
||||||
F --> J[Facial Recognition]
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
|||||||
|
|
||||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||||
|
|
||||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||||
|
|
||||||
The default value is `aac`.
|
The default value is `aac`.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The default configuration looks like this:
|
|||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"accel": "disabled",
|
"accel": "disabled",
|
||||||
"accelDecode": false,
|
"accelDecode": false,
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
"acceptedVideoCodecs": ["h264"],
|
"acceptedVideoCodecs": ["h264"],
|
||||||
"bframes": -1,
|
"bframes": -1,
|
||||||
|
|||||||
@@ -166,8 +166,6 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
|
||||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"start": "docusaurus start --port 3005",
|
"start": "docusaurus start --port 3005",
|
||||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||||
"build": "pnpm run copy:openapi && docusaurus build",
|
"build": "pnpm run copy:openapi && docusaurus build",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export enum OAuthClient {
|
|||||||
export enum OAuthUser {
|
export enum OAuthUser {
|
||||||
NO_EMAIL = 'no-email',
|
NO_EMAIL = 'no-email',
|
||||||
NO_NAME = 'no-name',
|
NO_NAME = 'no-name',
|
||||||
ID_TOKEN_CLAIMS = 'id-token-claims',
|
|
||||||
WITH_QUOTA = 'with-quota',
|
WITH_QUOTA = 'with-quota',
|
||||||
WITH_USERNAME = 'with-username',
|
WITH_USERNAME = 'with-username',
|
||||||
WITH_ROLE = 'with-role',
|
WITH_ROLE = 'with-role',
|
||||||
@@ -53,25 +52,12 @@ const withDefaultClaims = (sub: string) => ({
|
|||||||
email_verified: true,
|
email_verified: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getClaims = (sub: string, use?: string) => {
|
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||||
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
|
|
||||||
return {
|
|
||||||
sub,
|
|
||||||
email: `oauth-${sub}@immich.app`,
|
|
||||||
email_verified: true,
|
|
||||||
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||||
|
|
||||||
const redirectUris = [
|
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
||||||
'http://127.0.0.1:2285/auth/login',
|
|
||||||
'https://photos.immich.app/oauth/mobile-redirect',
|
|
||||||
];
|
|
||||||
const port = 2286;
|
const port = 2286;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
const oidc = new Provider(`http://${host}:${port}`, {
|
const oidc = new Provider(`http://${host}:${port}`, {
|
||||||
@@ -80,10 +66,7 @@ const setup = async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
ctx.body = 'Internal Server Error';
|
ctx.body = 'Internal Server Error';
|
||||||
},
|
},
|
||||||
findAccount: (ctx, sub) => ({
|
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||||
accountId: sub,
|
|
||||||
claims: (use) => getClaims(sub, use),
|
|
||||||
}),
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
claims: {
|
claims: {
|
||||||
openid: ['sub'],
|
openid: ['sub'],
|
||||||
@@ -111,7 +94,6 @@ const setup = async () => {
|
|||||||
state: 'oidc.state',
|
state: 'oidc.state',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
conformIdTokenClaims: false,
|
|
||||||
pkce: {
|
pkce: {
|
||||||
required: () => false,
|
required: () => false,
|
||||||
},
|
},
|
||||||
@@ -143,10 +125,7 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () =>
|
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||||
console.log(
|
|
||||||
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
|
|
||||||
);
|
|
||||||
const app = oidc.listen(port, host, onStart);
|
const app = oidc.listen(port, host, onStart);
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -14,8 +14,8 @@
|
|||||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.11.0",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@@ -54,8 +54,7 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
"vite-tsconfig-paths": "^6.1.1",
|
"vitest": "^3.0.0"
|
||||||
"vitest": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.13.1"
|
||||||
|
|||||||
@@ -380,23 +380,4 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('idTokenClaims', () => {
|
|
||||||
it('should use claims from the ID token if IDP includes them', async () => {
|
|
||||||
await setupOAuth(admin.accessToken, {
|
|
||||||
enabled: true,
|
|
||||||
clientId: OAuthClient.DEFAULT,
|
|
||||||
clientSecret: OAuthClient.DEFAULT,
|
|
||||||
});
|
|
||||||
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
|
||||||
expect(status).toBe(201);
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
accessToken: expect.any(String),
|
|
||||||
name: 'ID Token User',
|
|
||||||
userEmail: 'oauth-id-token-claims@immich.app',
|
|
||||||
userId: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -438,16 +438,6 @@ describe('/shared-links', () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject guests removing assets from an individual shared link', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
|
||||||
.query({ key: linkWithAssets.key })
|
|
||||||
.send({ assetIds: [asset1.id] });
|
|
||||||
|
|
||||||
expect(status).toBe(403);
|
|
||||||
expect(body).toEqual(errorDto.forbidden);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove assets from a shared link (individual)', async () => {
|
it('should remove assets from a shared link (individual)', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
import { expect, test } from '@playwright/test';
|
import { Page, expect, test } from '@playwright/test';
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
function imageLocator(page: Page) {
|
||||||
|
return page.getByAltText('Image taken').locator('visible=true');
|
||||||
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
let rawAsset: AssetMediaResponseDto;
|
let rawAsset: AssetMediaResponseDto;
|
||||||
let websocket: Socket;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
|
|||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(() => {
|
|
||||||
utils.disconnectWebsocket(websocket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ context, page }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
|
|||||||
|
|
||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||||
await originalResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /original/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||||
await fullsizeResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
|
||||||
const initialSrc = await preview.getAttribute('src');
|
|
||||||
|
|
||||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await websocketEvent;
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||||
|
|
||||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,18 +12,15 @@ import { asBearerAuth, utils } from 'src/utils';
|
|||||||
test.describe('Shared Links', () => {
|
test.describe('Shared Links', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
let asset2: AssetMediaResponseDto;
|
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let sharedLink: SharedLinkResponseDto;
|
let sharedLink: SharedLinkResponseDto;
|
||||||
let sharedLinkPassword: SharedLinkResponseDto;
|
let sharedLinkPassword: SharedLinkResponseDto;
|
||||||
let individualSharedLink: SharedLinkResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
asset2 = await utils.createAsset(admin.accessToken);
|
|
||||||
album = await createAlbum(
|
album = await createAlbum(
|
||||||
{
|
{
|
||||||
createAlbumDto: {
|
createAlbumDto: {
|
||||||
@@ -42,10 +39,6 @@ test.describe('Shared Links', () => {
|
|||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
password: 'test-password',
|
password: 'test-password',
|
||||||
});
|
});
|
||||||
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
|
||||||
type: SharedLinkType.Individual,
|
|
||||||
assetIds: [asset.id, asset2.id],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download from a shared link', async ({ page }) => {
|
test('download from a shared link', async ({ page }) => {
|
||||||
@@ -116,21 +109,4 @@ test.describe('Shared Links', () => {
|
|||||||
await page.waitForURL('/photos');
|
await page.waitForURL('/photos');
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
|
|
||||||
await page.goto(`/share/${individualSharedLink.key}`);
|
|
||||||
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
|
||||||
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
|
||||||
|
|
||||||
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
|
||||||
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
|
||||||
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
|
|
||||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||||
await context.route(
|
await context.route(
|
||||||
(url) =>
|
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
||||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
|
||||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
|
||||||
async (route) => {
|
async (route) => {
|
||||||
return route.fulfill({ status: 404 });
|
return route.fulfill({ status: 404 });
|
||||||
},
|
},
|
||||||
@@ -75,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|||||||
@@ -215,9 +215,8 @@ export const pageUtils = {
|
|||||||
await page.getByText('Confirm').click();
|
await page.getByText('Confirm').click();
|
||||||
},
|
},
|
||||||
async selectDay(page: Page, day: string) {
|
async selectDay(page: Page, day: string) {
|
||||||
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
|
await page.getByTitle(day).hover();
|
||||||
await section.hover();
|
await page.locator('[data-group] .w-8').click();
|
||||||
await section.locator('.w-8').click();
|
|
||||||
},
|
},
|
||||||
async pauseTestDebug() {
|
async pauseTestDebug() {
|
||||||
console.log('NOTE: pausing test indefinately for debug');
|
console.log('NOTE: pausing test indefinately for debug');
|
||||||
|
|||||||
+8
-19
@@ -177,6 +177,7 @@ export const utils = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetDatabase: async (tables?: string[]) => {
|
resetDatabase: async (tables?: string[]) => {
|
||||||
|
try {
|
||||||
client = await utils.connectDatabase();
|
client = await utils.connectDatabase();
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
@@ -196,33 +197,21 @@ export const utils = {
|
|||||||
'tag',
|
'tag',
|
||||||
];
|
];
|
||||||
|
|
||||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
|
||||||
const sql: string[] = [];
|
const sql: string[] = [];
|
||||||
|
|
||||||
if (truncateTables.length > 0) {
|
for (const table of tables) {
|
||||||
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
|
if (table === 'system_metadata') {
|
||||||
}
|
|
||||||
|
|
||||||
if (tables.includes('system_metadata')) {
|
|
||||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||||
|
} else {
|
||||||
|
sql.push(`DELETE FROM "${table}" CASCADE;`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = sql.join('\n');
|
await client.query(sql.join('\n'));
|
||||||
const maxRetries = 3;
|
} catch (error) {
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
await client.query(query);
|
|
||||||
return;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.code === '40P01' && attempt < maxRetries) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.error('Failed to reset database', error);
|
console.error('Failed to reset database', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
unzip: async (input: string, output: string) => {
|
unzip: async (input: string, output: string) => {
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./"
|
"baseUrl": "./"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
name: 'e2e:server',
|
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
maxWorkers: 1,
|
poolOptions: {
|
||||||
isolate: false,
|
threads: {
|
||||||
|
singleThread: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
name: 'e2e:maintenance',
|
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
maxWorkers: 1,
|
poolOptions: {
|
||||||
isolate: false,
|
threads: {
|
||||||
|
singleThread: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-9
@@ -411,7 +411,7 @@
|
|||||||
"transcoding_tone_mapping": "Tone-mapping",
|
"transcoding_tone_mapping": "Tone-mapping",
|
||||||
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
||||||
"transcoding_transcode_policy": "Transcode policy",
|
"transcoding_transcode_policy": "Transcode policy",
|
||||||
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).",
|
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
|
||||||
"transcoding_two_pass_encoding": "Two-pass encoding",
|
"transcoding_two_pass_encoding": "Two-pass encoding",
|
||||||
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
||||||
"transcoding_video_codec": "Video codec",
|
"transcoding_video_codec": "Video codec",
|
||||||
@@ -871,8 +871,8 @@
|
|||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
"current_server_address": "Current server address",
|
"current_server_address": "Current server address",
|
||||||
"custom_date": "Custom date",
|
"custom_date": "Custom date",
|
||||||
"custom_locale": "Custom locale",
|
"custom_locale": "Custom Locale",
|
||||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||||
"custom_url": "Custom URL",
|
"custom_url": "Custom URL",
|
||||||
"cutoff_date_description": "Keep photos from the last…",
|
"cutoff_date_description": "Keep photos from the last…",
|
||||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||||
@@ -895,6 +895,8 @@
|
|||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
"deduplication_info": "Deduplication Info",
|
"deduplication_info": "Deduplication Info",
|
||||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||||
|
"default_locale": "Default Locale",
|
||||||
|
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||||
"delete_action_prompt": "{count} deleted",
|
"delete_action_prompt": "{count} deleted",
|
||||||
@@ -1007,8 +1009,6 @@
|
|||||||
"editor_edits_applied_success": "Edits applied successfully",
|
"editor_edits_applied_success": "Edits applied successfully",
|
||||||
"editor_flip_horizontal": "Flip horizontal",
|
"editor_flip_horizontal": "Flip horizontal",
|
||||||
"editor_flip_vertical": "Flip vertical",
|
"editor_flip_vertical": "Flip vertical",
|
||||||
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
|
|
||||||
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
|
|
||||||
"editor_orientation": "Orientation",
|
"editor_orientation": "Orientation",
|
||||||
"editor_reset_all_changes": "Reset changes",
|
"editor_reset_all_changes": "Reset changes",
|
||||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||||
@@ -1074,7 +1074,7 @@
|
|||||||
"failed_to_update_notification_status": "Failed to update notification status",
|
"failed_to_update_notification_status": "Failed to update notification status",
|
||||||
"incorrect_email_or_password": "Incorrect email or password",
|
"incorrect_email_or_password": "Incorrect email or password",
|
||||||
"library_folder_already_exists": "This import path already exists.",
|
"library_folder_already_exists": "This import path already exists.",
|
||||||
"page_not_found": "Page not found",
|
"page_not_found": "Page not found :/",
|
||||||
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
||||||
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
||||||
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||||
@@ -1651,7 +1651,6 @@
|
|||||||
"only_favorites": "Only favorites",
|
"only_favorites": "Only favorites",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"open_calendar": "Open calendar",
|
"open_calendar": "Open calendar",
|
||||||
"open_in_browser": "Open in browser",
|
|
||||||
"open_in_map_view": "Open in map view",
|
"open_in_map_view": "Open in map view",
|
||||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||||
"open_the_search_filters": "Open the search filters",
|
"open_the_search_filters": "Open the search filters",
|
||||||
@@ -2339,8 +2338,6 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
"use_browser_locale": "Use browser locale",
|
|
||||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different ."
|
"format:fix": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
|||||||
@@ -48,11 +48,8 @@ class PreloadModelData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaxBatchSize(BaseModel):
|
class MaxBatchSize(BaseModel):
|
||||||
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
|
|
||||||
if ocr_fallback is not None:
|
|
||||||
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
|
|
||||||
facial_recognition: int | None = None
|
facial_recognition: int | None = None
|
||||||
ocr: int | None = None
|
text_recognition: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
|
|||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs)
|
super().__init__(model_name, **model_kwargs)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
|
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
|
||||||
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
|
|||||||
depends = []
|
depends = []
|
||||||
identity = (ModelType.DETECTION, ModelTask.OCR)
|
identity = (ModelType.DETECTION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
||||||
self.max_resolution = 736
|
self.max_resolution = 736
|
||||||
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||||
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
|
|||||||
}
|
}
|
||||||
self.postprocess = DBPostProcess(
|
self.postprocess = DBPostProcess(
|
||||||
thresh=0.3,
|
thresh=0.3,
|
||||||
box_thresh=model_kwargs.get("minScore", min_score),
|
box_thresh=model_kwargs.get("minScore", 0.5),
|
||||||
max_candidates=1000,
|
max_candidates=1000,
|
||||||
unclip_ratio=1.6,
|
unclip_ratio=1.6,
|
||||||
use_dilation=True,
|
use_dilation=True,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
|
|||||||
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
||||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
||||||
self.min_score = model_kwargs.get("minScore", min_score)
|
self.min_score = model_kwargs.get("minScore", 0.9)
|
||||||
self._empty: TextRecognitionOutput = {
|
self._empty: TextRecognitionOutput = {
|
||||||
"box": np.empty(0, dtype=np.float32),
|
"box": np.empty(0, dtype=np.float32),
|
||||||
"boxScore": np.empty(0, dtype=np.float32),
|
"boxScore": np.empty(0, dtype=np.float32),
|
||||||
@@ -57,11 +57,10 @@ class TextRecognizer(InferenceModel):
|
|||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtimes
|
# TODO: support other runtimes
|
||||||
session = OrtSession(self.model_path)
|
session = OrtSession(self.model_path)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
|
|
||||||
self.model = RapidTextRecognizer(
|
self.model = RapidTextRecognizer(
|
||||||
OcrOptions(
|
OcrOptions(
|
||||||
session=session.session,
|
session=session.session,
|
||||||
rec_batch_num=max_batch_size if max_batch_size else 6,
|
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
||||||
rec_img_shape=(3, 48, 320),
|
rec_img_shape=(3, 48, 320),
|
||||||
lang_type=self.language,
|
lang_type=self.language,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ class OrtSession:
|
|||||||
def _providers_default(self) -> list[str]:
|
def _providers_default(self) -> list[str]:
|
||||||
available_providers = set(ort.get_available_providers())
|
available_providers = set(ort.get_available_providers())
|
||||||
log.debug(f"Available ORT providers: {available_providers}")
|
log.debug(f"Available ORT providers: {available_providers}")
|
||||||
|
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||||
|
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||||
|
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||||
|
|
||||||
|
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||||
|
if not gpu_devices:
|
||||||
|
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||||
|
available_providers.remove(openvino)
|
||||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -94,19 +102,12 @@ class OrtSession:
|
|||||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
||||||
}
|
}
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
openvino_dir = self.model_path.parent / "openvino"
|
||||||
# Check for available devices, preferring GPU over CPU
|
device = f"GPU.{settings.device_id}"
|
||||||
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
|
|
||||||
if gpu_devices:
|
|
||||||
device_type = f"GPU.{settings.device_id}"
|
|
||||||
log.debug(f"OpenVINO: Using GPU device {device_type}")
|
|
||||||
else:
|
|
||||||
device_type = "CPU"
|
|
||||||
log.debug("OpenVINO: No GPU found, using CPU")
|
|
||||||
options = {
|
options = {
|
||||||
"device_type": device_type,
|
"device_type": device,
|
||||||
"precision": settings.openvino_precision.value,
|
"precision": settings.openvino_precision.value,
|
||||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
"cache_dir": openvino_dir.as_posix(),
|
||||||
}
|
}
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
@@ -138,14 +139,12 @@ class OrtSession:
|
|||||||
sess_options.enable_cpu_mem_arena = settings.model_arena
|
sess_options.enable_cpu_mem_arena = settings.model_arena
|
||||||
|
|
||||||
# avoid thread contention between models
|
# avoid thread contention between models
|
||||||
# Set inter_op threads
|
|
||||||
if settings.model_inter_op_threads > 0:
|
if settings.model_inter_op_threads > 0:
|
||||||
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
||||||
# these defaults work well for CPU, but bottleneck GPU
|
# these defaults work well for CPU, but bottleneck GPU
|
||||||
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
sess_options.inter_op_num_threads = 1
|
sess_options.inter_op_num_threads = 1
|
||||||
|
|
||||||
# Set intra_op threads
|
|
||||||
if settings.model_intra_op_threads > 0:
|
if settings.model_intra_op_threads > 0:
|
||||||
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
||||||
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
|
|||||||
+10
-110
@@ -18,7 +18,7 @@ from PIL import Image
|
|||||||
from pytest import MonkeyPatch
|
from pytest import MonkeyPatch
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from immich_ml.config import MaxBatchSize, Settings, settings
|
from immich_ml.config import Settings, settings
|
||||||
from immich_ml.main import load, preload_models
|
from immich_ml.main import load, preload_models
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
from immich_ml.models.cache import ModelCache
|
from immich_ml.models.cache import ModelCache
|
||||||
@@ -26,9 +26,6 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
|
|||||||
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
||||||
from immich_ml.models.facial_recognition.detection import FaceDetector
|
from immich_ml.models.facial_recognition.detection import FaceDetector
|
||||||
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
||||||
from immich_ml.models.ocr.detection import TextDetector
|
|
||||||
from immich_ml.models.ocr.recognition import TextRecognizer
|
|
||||||
from immich_ml.models.ocr.schemas import OcrOptions
|
|
||||||
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ann import AnnSession
|
from immich_ml.sessions.ann import AnnSession
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
@@ -204,6 +201,13 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert session.providers == self.OV_EP
|
assert session.providers == self.OV_EP
|
||||||
|
|
||||||
|
@pytest.mark.ov_device_ids(["CPU"])
|
||||||
|
@pytest.mark.providers(OV_EP)
|
||||||
|
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
|
||||||
|
session = OrtSession("ViT-B-32__openai")
|
||||||
|
|
||||||
|
assert session.providers == self.CPU_EP
|
||||||
|
|
||||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai")
|
session = OrtSession("ViT-B-32__openai")
|
||||||
@@ -249,8 +253,7 @@ class TestOrtSession:
|
|||||||
{"arena_extend_strategy": "kSameAsRequested"},
|
{"arena_extend_strategy": "kSameAsRequested"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_provider_options_for_openvino(self) -> None:
|
||||||
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -264,8 +267,7 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
||||||
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
||||||
@@ -280,19 +282,6 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.provider_options == [
|
|
||||||
{
|
|
||||||
"device_type": "CPU",
|
|
||||||
"precision": "FP32",
|
|
||||||
"cache_dir": "/cache/ViT-B-32__openai/openvino",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_sets_provider_options_for_cuda(self) -> None:
|
def test_sets_provider_options_for_cuda(self) -> None:
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -349,23 +338,6 @@ class TestOrtSession:
|
|||||||
assert session.sess_options.inter_op_num_threads == 1
|
assert session.sess_options.inter_op_num_threads == 1
|
||||||
assert session.sess_options.intra_op_num_threads == 2
|
assert session.sess_options.intra_op_num_threads == 2
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||||||
|
|
||||||
@@ -883,78 +855,6 @@ class TestFaceRecognition:
|
|||||||
onnx.load.assert_not_called()
|
onnx.load.assert_not_called()
|
||||||
onnx.save.assert_not_called()
|
onnx.save.assert_not_called()
|
||||||
|
|
||||||
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
|
||||||
|
|
||||||
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert recognizer.batch_size == 2
|
|
||||||
|
|
||||||
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
|
|
||||||
|
|
||||||
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert recognizer.batch_size is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestOcr:
|
|
||||||
def test_set_det_min_score(self, path: mock.Mock) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
|
|
||||||
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert text_detector.postprocess.box_thresh == 0.8
|
|
||||||
|
|
||||||
def test_set_rec_min_score(self, path: mock.Mock) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert text_recognizer.min_score == 0.8
|
|
||||||
|
|
||||||
def test_set_rec_set_default_max_batch_size(
|
|
||||||
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
||||||
) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_ignore_other_custom_max_batch_size(
|
|
||||||
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
||||||
) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestCache:
|
class TestCache:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.13.1"
|
node = "24.13.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.30.3"
|
pnpm = "10.30.0"
|
||||||
terragrunt = "0.99.4"
|
terragrunt = "0.98.0"
|
||||||
opentofu = "1.11.5"
|
opentofu = "1.11.4"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ linter:
|
|||||||
require_trailing_commas: true
|
require_trailing_commas: true
|
||||||
unrelated_type_equality_checks: true
|
unrelated_type_equality_checks: true
|
||||||
prefer_const_constructors: true
|
prefer_const_constructors: true
|
||||||
always_use_package_imports: true
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ plugins {
|
|||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -82,7 +81,6 @@ android {
|
|||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
namespace 'app.alextran.immich'
|
namespace 'app.alextran.immich'
|
||||||
@@ -113,8 +111,6 @@ dependencies {
|
|||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
|
|
||||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||||
|
|||||||
-8
@@ -37,11 +37,3 @@
|
|||||||
|
|
||||||
# Keep all widget model classes and their fields for Gson
|
# Keep all widget model classes and their fields for Gson
|
||||||
-keep class app.alextran.immich.widget.model.** { *; }
|
-keep class app.alextran.immich.widget.model.** { *; }
|
||||||
|
|
||||||
##---------------Begin: proguard configuration for ok_http JNI ----------
|
|
||||||
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
|
|
||||||
# string-based reflection (JClass.forName), which R8 cannot trace.
|
|
||||||
-keep class okhttp3.** { *; }
|
|
||||||
-keep class okio.** { *; }
|
|
||||||
-keep class com.example.ok_http.** { *; }
|
|
||||||
##---------------End: proguard configuration for ok_http JNI ----------
|
|
||||||
|
|||||||
@@ -36,17 +36,3 @@ Java_app_alextran_immich_NativeBuffer_copy(
|
|||||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a JNI global reference to the given object and returns its address.
|
|
||||||
* The caller is responsible for deleting the global reference when it's no longer needed.
|
|
||||||
*/
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
|
||||||
if (obj == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
|
||||||
return (jlong) globalRef;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
|
|||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import app.alextran.immich.core.NetworkApiPlugin
|
import app.alextran.immich.core.NetworkApiPlugin
|
||||||
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
|
||||||
import app.alextran.immich.images.LocalImageApi
|
import app.alextran.immich.images.LocalImageApi
|
||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
@@ -32,7 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
|
||||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ object NativeBuffer {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun createGlobalRef(obj: Any): Long
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NativeByteBuffer(initialCapacity: Int) {
|
class NativeByteBuffer(initialCapacity: Int) {
|
||||||
|
|||||||
@@ -1,43 +1,18 @@
|
|||||||
package app.alextran.immich.core
|
package app.alextran.immich.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.security.KeyChain
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.datasource.DataSource
|
|
||||||
import androidx.media3.datasource.ResolvingDataSource
|
|
||||||
import androidx.media3.datasource.cronet.CronetDataSource
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
import app.alextran.immich.NativeBuffer
|
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.Cookie
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.Dispatcher
|
import okhttp3.Dispatcher
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.chromium.net.CronetEngine
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Authenticator
|
|
||||||
import java.net.CookieHandler
|
|
||||||
import java.net.PasswordAuthentication
|
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URI
|
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.Principal
|
import java.security.Principal
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import java.util.concurrent.ExecutorService
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
@@ -45,31 +20,14 @@ import javax.net.ssl.TrustManagerFactory
|
|||||||
import javax.net.ssl.X509KeyManager
|
import javax.net.ssl.X509KeyManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
const val CERT_ALIAS = "client_cert"
|
||||||
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
||||||
private const val CERT_ALIAS = "client_cert"
|
|
||||||
private const val PREFS_NAME = "immich.ssl"
|
|
||||||
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
|
||||||
private const val PREFS_HEADERS = "immich.request_headers"
|
|
||||||
private const val PREFS_SERVER_URLS = "immich.server_urls"
|
|
||||||
private const val PREFS_COOKIES = "immich.cookies"
|
|
||||||
private const val COOKIE_EXPIRY_DAYS = 400L
|
|
||||||
|
|
||||||
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
|
||||||
ACCESS_TOKEN("immich_access_token", httpOnly = true),
|
|
||||||
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
|
|
||||||
AUTH_TYPE("immich_auth_type", httpOnly = true);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val names = entries.map { it.cookieName }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a shared OkHttpClient with SSL configuration support.
|
* Manages a shared OkHttpClient with SSL configuration support.
|
||||||
*/
|
*/
|
||||||
object HttpClientManager {
|
object HttpClientManager {
|
||||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
||||||
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
|
|
||||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||||
private const val MAX_REQUESTS_PER_HOST = 64
|
private const val MAX_REQUESTS_PER_HOST = 64
|
||||||
@@ -78,93 +36,22 @@ object HttpClientManager {
|
|||||||
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: OkHttpClient
|
||||||
private lateinit var appContext: Context
|
|
||||||
private lateinit var prefs: SharedPreferences
|
|
||||||
|
|
||||||
var cronetEngine: CronetEngine? = null
|
|
||||||
private set
|
|
||||||
private lateinit var cronetStorageDir: File
|
|
||||||
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
|
|
||||||
|
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
var keyChainAlias: String? = null
|
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
|
||||||
private set
|
|
||||||
|
|
||||||
var headers: Headers = Headers.headersOf()
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val cookieJar = PersistentCookieJar()
|
|
||||||
|
|
||||||
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
|
|
||||||
appContext = context.applicationContext
|
|
||||||
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
|
||||||
|
|
||||||
cookieJar.init(prefs)
|
|
||||||
System.setProperty("http.agent", USER_AGENT)
|
|
||||||
Authenticator.setDefault(object : Authenticator() {
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
|
||||||
val url = requestingURL ?: return null
|
|
||||||
if (url.userInfo.isNullOrEmpty()) return null
|
|
||||||
val parts = url.userInfo.split(":", limit = 2)
|
|
||||||
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
CookieHandler.setDefault(object : CookieHandler() {
|
|
||||||
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
|
|
||||||
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
|
|
||||||
val cookies = cookieJar.loadForRequest(httpUrl)
|
|
||||||
if (cookies.isEmpty()) return emptyMap()
|
|
||||||
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
|
||||||
if (savedHeaders != null) {
|
|
||||||
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
for ((key, value) in map) {
|
|
||||||
builder.add(key, value)
|
|
||||||
}
|
|
||||||
headers = builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
|
|
||||||
if (serverUrlsJson != null) {
|
|
||||||
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
|
|
||||||
}
|
|
||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
|
|
||||||
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
|
|
||||||
cronetEngine = buildCronetEngine()
|
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setKeyChainAlias(alias: String) {
|
|
||||||
synchronized(this) {
|
|
||||||
val wasMtls = isMtls
|
|
||||||
keyChainAlias = alias
|
|
||||||
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
|
|
||||||
|
|
||||||
if (wasMtls != isMtls) {
|
|
||||||
clientChangedListeners.forEach { it() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val wasMtls = isMtls
|
val wasMtls = isMtls
|
||||||
@@ -176,7 +63,7 @@ object HttpClientManager {
|
|||||||
val key = tmpKeyStore.getKey(tmpAlias, password)
|
val key = tmpKeyStore.getKey(tmpAlias, password)
|
||||||
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
||||||
|
|
||||||
if (keyStore.containsAlias(CERT_ALIAS)) {
|
if (wasMtls) {
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
}
|
}
|
||||||
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
||||||
@@ -188,130 +75,24 @@ object HttpClientManager {
|
|||||||
|
|
||||||
fun deleteKeyEntry() {
|
fun deleteKeyEntry() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val wasMtls = isMtls
|
if (!isMtls) {
|
||||||
|
return
|
||||||
if (keyChainAlias != null) {
|
|
||||||
keyChainAlias = null
|
|
||||||
prefs.edit { remove(PREFS_CERT_ALIAS) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
|
|
||||||
if (wasMtls) {
|
|
||||||
clientChangedListeners.forEach { it() }
|
clientChangedListeners.forEach { it() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var clientGlobalRef: Long = 0L
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getClient(): OkHttpClient {
|
fun getClient(): OkHttpClient {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClientPointer(): Long {
|
|
||||||
if (clientGlobalRef == 0L) {
|
|
||||||
clientGlobalRef = NativeBuffer.createGlobalRef(client)
|
|
||||||
}
|
|
||||||
return clientGlobalRef
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addClientChangedListener(listener: () -> Unit) {
|
fun addClientChangedListener(listener: () -> Unit) {
|
||||||
synchronized(this) { clientChangedListeners.add(listener) }
|
synchronized(this) { clientChangedListeners.add(listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
|
|
||||||
synchronized(this) {
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
headerMap.forEach { (key, value) -> builder[key] = value }
|
|
||||||
val newHeaders = builder.build()
|
|
||||||
|
|
||||||
val headersChanged = headers != newHeaders
|
|
||||||
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
|
|
||||||
|
|
||||||
headers = newHeaders
|
|
||||||
cookieJar.setServerUrls(serverUrls)
|
|
||||||
|
|
||||||
if (headersChanged || urlsChanged) {
|
|
||||||
prefs.edit {
|
|
||||||
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
|
|
||||||
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token != null) {
|
|
||||||
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
|
|
||||||
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
|
|
||||||
val values = mapOf(
|
|
||||||
AuthCookie.ACCESS_TOKEN to token,
|
|
||||||
AuthCookie.IS_AUTHENTICATED to "true",
|
|
||||||
AuthCookie.AUTH_TYPE to "password",
|
|
||||||
)
|
|
||||||
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
|
|
||||||
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
|
|
||||||
.apply {
|
|
||||||
if (url.isHttps) secure()
|
|
||||||
if (cookie.httpOnly) httpOnly()
|
|
||||||
}.build()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadCookieHeader(url: String): String? {
|
|
||||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
|
||||||
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
|
|
||||||
?.joinToString("; ") { "${it.name}=${it.value}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAuthHeaders(url: String): Map<String, String> {
|
|
||||||
val result = mutableMapOf<String, String>()
|
|
||||||
headers.forEach { (key, value) -> result[key] = value }
|
|
||||||
loadCookieHeader(url)?.let { result["Cookie"] = it }
|
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
|
||||||
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rebuildCronetEngine(): CronetEngine {
|
|
||||||
val old = cronetEngine!!
|
|
||||||
cronetEngine = buildCronetEngine()
|
|
||||||
return old
|
|
||||||
}
|
|
||||||
|
|
||||||
val cronetStoragePath: File get() = cronetStorageDir
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
|
|
||||||
return if (isMtls) {
|
|
||||||
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
|
|
||||||
} else {
|
|
||||||
ResolvingDataSource.Factory(
|
|
||||||
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
|
|
||||||
) { dataSpec ->
|
|
||||||
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
|
|
||||||
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
|
|
||||||
newHeaders["Cache-Control"] = "no-store"
|
|
||||||
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildCronetEngine(): CronetEngine {
|
|
||||||
return CronetEngine.Builder(appContext)
|
|
||||||
.enableHttp2(true)
|
|
||||||
.enableQuic(true)
|
|
||||||
.enableBrotli(true)
|
|
||||||
.setStoragePath(cronetStorageDir.absolutePath)
|
|
||||||
.setUserAgent(USER_AGENT)
|
|
||||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build(cacheDir: File): OkHttpClient {
|
private fun build(cacheDir: File): OkHttpClient {
|
||||||
val connectionPool = ConnectionPool(
|
val connectionPool = ConnectionPool(
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||||
@@ -328,17 +109,8 @@ object HttpClientManager {
|
|||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.cookieJar(cookieJar)
|
.addInterceptor { chain ->
|
||||||
.addInterceptor {
|
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
|
||||||
val request = it.request()
|
|
||||||
val builder = request.newBuilder()
|
|
||||||
builder.header("User-Agent", USER_AGENT)
|
|
||||||
headers.forEach { (key, value) -> builder.header(key, value) }
|
|
||||||
val url = request.url
|
|
||||||
if (url.username.isNotEmpty()) {
|
|
||||||
builder.header("Authorization", Credentials.basic(url.username, url.password))
|
|
||||||
}
|
|
||||||
it.proceed(builder.build())
|
|
||||||
}
|
}
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
||||||
@@ -347,39 +119,23 @@ object HttpClientManager {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Reads from the key store rather than taking a snapshot at initialization time
|
||||||
* Resolves client certificates dynamically at TLS handshake time.
|
|
||||||
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
|
|
||||||
*/
|
|
||||||
private class DynamicKeyManager : X509KeyManager {
|
private class DynamicKeyManager : X509KeyManager {
|
||||||
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
|
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
||||||
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
|
if (isMtls) arrayOf(CERT_ALIAS) else null
|
||||||
return arrayOf(alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chooseClientAlias(
|
override fun chooseClientAlias(
|
||||||
keyTypes: Array<String>,
|
keyTypes: Array<String>,
|
||||||
issuers: Array<Principal>?,
|
issuers: Array<Principal>?,
|
||||||
socket: Socket?
|
socket: Socket?
|
||||||
): String? {
|
): String? =
|
||||||
keyChainAlias?.let { return it }
|
if (isMtls) CERT_ALIAS else null
|
||||||
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
|
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
|
||||||
if (alias == keyChainAlias) {
|
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
||||||
return KeyChain.getCertificateChain(appContext, alias)
|
|
||||||
}
|
|
||||||
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPrivateKey(alias: String): PrivateKey? {
|
override fun getPrivateKey(alias: String): PrivateKey? =
|
||||||
if (alias == keyChainAlias) {
|
keyStore.getKey(alias, null) as? PrivateKey
|
||||||
return KeyChain.getPrivateKey(appContext, alias)
|
|
||||||
}
|
|
||||||
return keyStore.getKey(alias, null) as? PrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
||||||
null
|
null
|
||||||
@@ -390,131 +146,4 @@ object HttpClientManager {
|
|||||||
socket: Socket?
|
socket: Socket?
|
||||||
): String? = null
|
): String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
|
|
||||||
* When the server sets cookies for one domain, copies are created for all other known
|
|
||||||
* server domains (for URL switching between local/remote endpoints of the same server).
|
|
||||||
*/
|
|
||||||
private class PersistentCookieJar : CookieJar {
|
|
||||||
private val store = mutableListOf<Cookie>()
|
|
||||||
private var serverUrls = listOf<HttpUrl>()
|
|
||||||
private var prefs: SharedPreferences? = null
|
|
||||||
|
|
||||||
|
|
||||||
fun init(prefs: SharedPreferences) {
|
|
||||||
this.prefs = prefs
|
|
||||||
restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setServerUrls(urls: List<String>) {
|
|
||||||
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
|
|
||||||
if (parsed.map { it.host } == serverUrls.map { it.host }) return
|
|
||||||
serverUrls = parsed
|
|
||||||
if (syncAuthCookies()) persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
|
||||||
val changed = cookies.any { new ->
|
|
||||||
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
|
|
||||||
}
|
|
||||||
store.removeAll { existing ->
|
|
||||||
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
|
||||||
}
|
|
||||||
store.addAll(cookies)
|
|
||||||
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
|
|
||||||
if (changed || synced) persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (store.removeAll { it.expiresAt < now }) {
|
|
||||||
syncAuthCookies()
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
return store.filter { it.matches(url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncAuthCookies(): Boolean {
|
|
||||||
val serverHosts = serverUrls.map { it.host }.toSet()
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val sourceCookies = store
|
|
||||||
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
|
|
||||||
.associateBy { it.name }
|
|
||||||
|
|
||||||
if (sourceCookies.isEmpty()) {
|
|
||||||
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
|
|
||||||
}
|
|
||||||
|
|
||||||
var changed = false
|
|
||||||
for (url in serverUrls) {
|
|
||||||
for ((_, source) in sourceCookies) {
|
|
||||||
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
|
|
||||||
store.removeAll { it.name == source.name && it.domain == url.host }
|
|
||||||
store.add(rebuildCookie(source, url))
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
|
|
||||||
return Cookie.Builder()
|
|
||||||
.name(source.name).value(source.value)
|
|
||||||
.domain(url.host).path("/")
|
|
||||||
.expiresAt(source.expiresAt)
|
|
||||||
.apply {
|
|
||||||
if (url.isHttps) secure()
|
|
||||||
if (source.httpOnly) httpOnly()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persist() {
|
|
||||||
val p = prefs ?: return
|
|
||||||
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restore() {
|
|
||||||
val p = prefs ?: return
|
|
||||||
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
|
|
||||||
try {
|
|
||||||
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
|
|
||||||
} catch (_: Exception) {
|
|
||||||
store.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class SerializedCookie(
|
|
||||||
val name: String,
|
|
||||||
val value: String,
|
|
||||||
val domain: String,
|
|
||||||
val path: String,
|
|
||||||
val expiresAt: Long,
|
|
||||||
val secure: Boolean,
|
|
||||||
val httpOnly: Boolean,
|
|
||||||
val hostOnly: Boolean,
|
|
||||||
) {
|
|
||||||
fun toCookie(): Cookie = Cookie.Builder()
|
|
||||||
.name(name).value(value).path(path).expiresAt(expiresAt)
|
|
||||||
.apply {
|
|
||||||
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
|
||||||
if (secure) secure()
|
|
||||||
if (httpOnly) httpOnly()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(cookie: Cookie) = SerializedCookie(
|
|
||||||
name = cookie.name, value = cookie.value, domain = cookie.domain,
|
|
||||||
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
|
|
||||||
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,11 +180,8 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
|
|||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NetworkApi {
|
interface NetworkApi {
|
||||||
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
||||||
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
|
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
|
||||||
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
||||||
fun hasCertificate(): Boolean
|
|
||||||
fun getClientPointer(): Long
|
|
||||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NetworkApi. */
|
/** The codec used by NetworkApi. */
|
||||||
@@ -220,12 +217,13 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val promptTextArg = args[0] as ClientCertPrompt
|
val promptTextArg = args[0] as ClientCertPrompt
|
||||||
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
|
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(NetworkPigeonUtils.wrapError(error))
|
reply.reply(NetworkPigeonUtils.wrapError(error))
|
||||||
} else {
|
} else {
|
||||||
reply.reply(NetworkPigeonUtils.wrapResult(null))
|
val data = result.getOrNull()
|
||||||
|
reply.reply(NetworkPigeonUtils.wrapResult(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,56 +248,6 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { _, reply ->
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
listOf(api.hasCertificate())
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { _, reply ->
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
listOf(api.getClientPointer())
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { message, reply ->
|
|
||||||
val args = message as List<Any?>
|
|
||||||
val headersArg = args[0] as Map<String, String>
|
|
||||||
val serverUrlsArg = args[1] as List<String>
|
|
||||||
val tokenArg = args[2] as String?
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
|
|
||||||
listOf(null)
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ package app.alextran.immich.core
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.security.KeyChain
|
import android.text.InputType
|
||||||
import app.alextran.immich.NativeBuffer
|
import android.view.ContextThemeWrapper
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -13,7 +24,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
private var networkApi: NetworkApiImpl? = null
|
private var networkApi: NetworkApiImpl? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
networkApi = NetworkApiImpl()
|
networkApi = NetworkApiImpl(binding.applicationContext)
|
||||||
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,24 +34,48 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
networkApi?.activity = binding.activity
|
networkApi?.onAttachedToActivity(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
networkApi?.activity = null
|
networkApi?.onDetachedFromActivityForConfigChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
networkApi?.activity = binding.activity
|
networkApi?.onReattachedToActivityForConfigChanges(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
networkApi?.activity = null
|
networkApi?.onDetachedFromActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkApiImpl : NetworkApi {
|
private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
||||||
var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
|
||||||
|
private var filePicker: ActivityResultLauncher<Array<String>>? = null
|
||||||
|
private var promptText: ClientCertPrompt? = null
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
(binding.activity as? ComponentActivity)?.let { componentActivity ->
|
||||||
|
filePicker = componentActivity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
||||||
try {
|
try {
|
||||||
@@ -51,19 +86,11 @@ private class NetworkApiImpl : NetworkApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
|
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
|
||||||
val currentActivity = activity
|
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
|
||||||
?: return callback(Result.failure(IllegalStateException("No activity")))
|
pendingCallback = callback
|
||||||
|
this.promptText = promptText
|
||||||
val onAlias = { alias: String? ->
|
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
|
||||||
if (alias != null) {
|
|
||||||
HttpClientManager.setKeyChainAlias(alias)
|
|
||||||
callback(Result.success(Unit))
|
|
||||||
} else {
|
|
||||||
callback(Result.failure(OperationCanceledException()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
||||||
@@ -71,15 +98,62 @@ private class NetworkApiImpl : NetworkApi {
|
|||||||
callback(Result.success(Unit))
|
callback(Result.success(Unit))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasCertificate(): Boolean {
|
private fun handlePickedFile(uri: Uri) {
|
||||||
return HttpClientManager.isMtls
|
val callback = pendingCallback ?: return
|
||||||
|
pendingCallback = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: throw IllegalStateException("Could not read file")
|
||||||
|
|
||||||
|
val activity = activity ?: throw IllegalStateException("No activity")
|
||||||
|
promptForPassword(activity) { password ->
|
||||||
|
promptText = null
|
||||||
|
if (password == null) {
|
||||||
|
callback(Result.failure(OperationCanceledException()))
|
||||||
|
return@promptForPassword
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
HttpClientManager.setKeyEntry(data, password.toCharArray())
|
||||||
|
callback(Result.success(ClientCertData(data, password)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getClientPointer(): Long {
|
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
|
||||||
return HttpClientManager.getClientPointer()
|
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
|
||||||
|
val density = activity.resources.displayMetrics.density
|
||||||
|
val horizontalPadding = (24 * density).toInt()
|
||||||
|
|
||||||
|
val textInputLayout = TextInputLayout(themedContext).apply {
|
||||||
|
hint = "Password"
|
||||||
|
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
|
||||||
|
setMargins(horizontalPadding, 0, horizontalPadding, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
|
val editText = TextInputEditText(textInputLayout.context).apply {
|
||||||
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
|
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||||
|
}
|
||||||
|
textInputLayout.addView(editText)
|
||||||
|
|
||||||
|
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
|
||||||
|
|
||||||
|
val text = promptText!!
|
||||||
|
MaterialAlertDialogBuilder(themedContext)
|
||||||
|
.setTitle(text.title)
|
||||||
|
.setMessage(text.message)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
|
||||||
|
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
|
||||||
|
.setOnCancelListener { callback(null) }
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface RemoteImageApi {
|
interface RemoteImageApi {
|
||||||
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||||
|
|
||||||
@@ -66,9 +66,10 @@ interface RemoteImageApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val urlArg = args[0] as String
|
val urlArg = args[0] as String
|
||||||
val requestIdArg = args[1] as Long
|
val headersArg = args[1] as Map<String, String>
|
||||||
val preferEncodedArg = args[2] as Boolean
|
val requestIdArg = args[2] as Long
|
||||||
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
val preferEncodedArg = args[3] as Boolean
|
||||||
|
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
|
|||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
import app.alextran.immich.NativeByteBuffer
|
||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
|
import app.alextran.immich.core.USER_AGENT
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
@@ -14,6 +15,7 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.chromium.net.CronetEngine
|
||||||
import org.chromium.net.CronetException
|
import org.chromium.net.CronetException
|
||||||
import org.chromium.net.UrlRequest
|
import org.chromium.net.UrlRequest
|
||||||
import org.chromium.net.UrlResponseInfo
|
import org.chromium.net.UrlResponseInfo
|
||||||
@@ -27,6 +29,10 @@ import java.nio.file.Path
|
|||||||
import java.nio.file.SimpleFileVisitor
|
import java.nio.file.SimpleFileVisitor
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
|
||||||
|
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
override fun requestImage(
|
override fun requestImage(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
@@ -52,6 +59,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
ImageFetcherManager.fetch(
|
ImageFetcherManager.fetch(
|
||||||
url,
|
url,
|
||||||
|
headers,
|
||||||
signal,
|
signal,
|
||||||
onSuccess = { buffer ->
|
onSuccess = { buffer ->
|
||||||
requestMap.remove(requestId)
|
requestMap.remove(requestId)
|
||||||
@@ -93,6 +101,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private object ImageFetcherManager {
|
private object ImageFetcherManager {
|
||||||
|
private lateinit var appContext: Context
|
||||||
private lateinit var cacheDir: File
|
private lateinit var cacheDir: File
|
||||||
private lateinit var fetcher: ImageFetcher
|
private lateinit var fetcher: ImageFetcher
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
@@ -101,6 +110,7 @@ private object ImageFetcherManager {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
|
appContext = context.applicationContext
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
HttpClientManager.addClientChangedListener(::invalidate)
|
HttpClientManager.addClientChangedListener(::invalidate)
|
||||||
@@ -110,11 +120,12 @@ private object ImageFetcherManager {
|
|||||||
|
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
) {
|
) {
|
||||||
fetcher.fetch(url, signal, onSuccess, onFailure)
|
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
||||||
@@ -133,7 +144,7 @@ private object ImageFetcherManager {
|
|||||||
return if (HttpClientManager.isMtls) {
|
return if (HttpClientManager.isMtls) {
|
||||||
OkHttpImageFetcher.create(cacheDir)
|
OkHttpImageFetcher.create(cacheDir)
|
||||||
} else {
|
} else {
|
||||||
CronetImageFetcher()
|
CronetImageFetcher(appContext, cacheDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +152,7 @@ private object ImageFetcherManager {
|
|||||||
private sealed interface ImageFetcher {
|
private sealed interface ImageFetcher {
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -151,14 +163,23 @@ private sealed interface ImageFetcher {
|
|||||||
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CronetImageFetcher : ImageFetcher {
|
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
||||||
|
private val ctx = context
|
||||||
|
private var engine: CronetEngine
|
||||||
|
private val executor = Executors.newFixedThreadPool(4)
|
||||||
private val stateLock = Any()
|
private val stateLock = Any()
|
||||||
private var activeCount = 0
|
private var activeCount = 0
|
||||||
private var draining = false
|
private var draining = false
|
||||||
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
||||||
|
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
||||||
|
|
||||||
|
init {
|
||||||
|
engine = build(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -172,16 +193,24 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = HttpClientManager.cronetEngine!!
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
|
|
||||||
requestBuilder.addHeader(key, value)
|
|
||||||
}
|
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun build(ctx: Context): CronetEngine {
|
||||||
|
return CronetEngine.Builder(ctx)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableBrotli(true)
|
||||||
|
.setStoragePath(storageDir.absolutePath)
|
||||||
|
.setUserAgent(USER_AGENT)
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
private fun onComplete() {
|
private fun onComplete() {
|
||||||
val didDrain = synchronized(stateLock) {
|
val didDrain = synchronized(stateLock) {
|
||||||
activeCount--
|
activeCount--
|
||||||
@@ -204,16 +233,19 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onDrained() {
|
private fun onDrained() {
|
||||||
|
engine.shutdown()
|
||||||
val onCacheCleared = synchronized(stateLock) {
|
val onCacheCleared = synchronized(stateLock) {
|
||||||
val onCacheCleared = onCacheCleared
|
val onCacheCleared = onCacheCleared
|
||||||
this.onCacheCleared = null
|
this.onCacheCleared = null
|
||||||
onCacheCleared
|
onCacheCleared
|
||||||
}
|
}
|
||||||
if (onCacheCleared != null) {
|
if (onCacheCleared == null) {
|
||||||
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
executor.shutdown()
|
||||||
oldEngine.shutdown()
|
} else {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
|
||||||
|
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
|
||||||
|
engine = build(ctx)
|
||||||
synchronized(stateLock) { draining = false }
|
synchronized(stateLock) { draining = false }
|
||||||
onCacheCleared(result)
|
onCacheCleared(result)
|
||||||
}
|
}
|
||||||
@@ -340,7 +372,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
val dir = File(cacheDir, "okhttp")
|
val dir = File(cacheDir, "okhttp")
|
||||||
|
|
||||||
val client = HttpClientManager.getClient().newBuilder()
|
val client = HttpClientManager.getClient().newBuilder()
|
||||||
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
|
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return OkHttpImageFetcher(client)
|
return OkHttpImageFetcher(client)
|
||||||
@@ -359,6 +391,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -371,6 +404,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val requestBuilder = Request.Builder().url(url)
|
val requestBuilder = Request.Builder().url(url)
|
||||||
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
val call = client.newCall(requestBuilder.build())
|
val call = client.newCall(requestBuilder.build())
|
||||||
signal.setOnCancelListener(call::cancel)
|
signal.setOnCancelListener(call::cancel)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import app.alextran.immich.core.ImmichPlugin
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -82,14 +81,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
if (hasSpecialFormatColumn()) {
|
if (hasSpecialFormatColumn()) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// fallback to mimetype and xmp for playback style detection on older Android versions
|
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||||
// both only needed if special format column is not available
|
// only needed if SPECIAL_FORMAT column isn't available
|
||||||
add(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
add(MediaStore.MediaColumns.XMP)
|
add(MediaStore.MediaColumns.XMP)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
@@ -135,7 +131,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||||
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
||||||
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
@@ -182,20 +177,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||||
|
|
||||||
val playbackStyle = detectPlaybackStyle(
|
val playbackStyle = detectPlaybackStyle(
|
||||||
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
|
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
||||||
)
|
)
|
||||||
|
|
||||||
val isFlipped = orientation == 90 || orientation == 270
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assetType,
|
assetType,
|
||||||
createdAt,
|
createdAt,
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
if (isFlipped) height else width,
|
width,
|
||||||
if (isFlipped) width else height,
|
height,
|
||||||
duration,
|
duration,
|
||||||
0L,
|
orientation.toLong(),
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
playbackStyle = playbackStyle,
|
||||||
)
|
)
|
||||||
@@ -206,14 +200,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
|
* Detects the playback style for an asset using _special_format (API 33+)
|
||||||
* or XMP / MIME / RIFF header fallbacks.
|
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
||||||
*/
|
*/
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
private fun detectPlaybackStyle(
|
private fun detectPlaybackStyle(
|
||||||
assetId: Long,
|
assetId: Long,
|
||||||
rawMediaType: Int,
|
rawMediaType: Int,
|
||||||
mimeTypeColumn: Int,
|
|
||||||
specialFormatColumn: Int,
|
specialFormatColumn: Int,
|
||||||
xmpColumn: Int,
|
xmpColumn: Int,
|
||||||
cursor: Cursor
|
cursor: Cursor
|
||||||
@@ -238,55 +231,45 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
|
// Pre-API 33 fallback
|
||||||
|
|
||||||
// GIFs are always animated and cannot be motion photos; no I/O needed
|
|
||||||
if (mimeType == "image/gif") {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||||
assetId
|
assetId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only WebP needs a stream check to distinguish static vs animated;
|
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
||||||
// WebP files are not used as motion photos, so skip XMP detection
|
val xmp: String? = if (xmpColumn != -1) {
|
||||||
if (mimeType == "image/webp") {
|
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
val glide = Glide.get(ctx)
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||||
|
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
val glide = Glide.get(ctx)
|
||||||
val type = ImageHeaderParserUtils.getType(
|
val type = ImageHeaderParserUtils.getType(
|
||||||
listOf(DefaultImageHeaderParser()),
|
glide.registry.imageHeaderParsers,
|
||||||
stream,
|
stream,
|
||||||
glide.arrayPool
|
glide.arrayPool
|
||||||
)
|
)
|
||||||
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
|
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
||||||
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||||
}
|
}
|
||||||
// if mimeType is webp but not animated, its just an image.
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+)
|
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
|
||||||
} else {
|
|
||||||
// if xmp column is not available, we are on API 29 or below
|
|
||||||
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
|
|
||||||
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
return PlatformAssetPlaybackStyle.IMAGE
|
||||||
}
|
}
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
import native_video_player
|
|
||||||
import network_info_plus
|
import network_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
@@ -19,8 +18,6 @@ import UIKit
|
|||||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
|
|
||||||
URLSessionManager.patchBackgroundDownloader()
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
||||||
|
|||||||
@@ -221,11 +221,8 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NetworkApi {
|
protocol NetworkApi {
|
||||||
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
|
||||||
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func hasCertificate() throws -> Bool
|
|
||||||
func getClientPointer() throws -> Int64
|
|
||||||
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -258,8 +255,8 @@ class NetworkApiSetup {
|
|||||||
let promptTextArg = args[0] as! ClientCertPrompt
|
let promptTextArg = args[0] as! ClientCertPrompt
|
||||||
api.selectCertificate(promptText: promptTextArg) { result in
|
api.selectCertificate(promptText: promptTextArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success(let res):
|
||||||
reply(wrapResult(nil))
|
reply(wrapResult(res))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
}
|
}
|
||||||
@@ -283,48 +280,5 @@ class NetworkApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
removeCertificateChannel.setMessageHandler(nil)
|
removeCertificateChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
hasCertificateChannel.setMessageHandler { _, reply in
|
|
||||||
do {
|
|
||||||
let result = try api.hasCertificate()
|
|
||||||
reply(wrapResult(result))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hasCertificateChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
getClientPointerChannel.setMessageHandler { _, reply in
|
|
||||||
do {
|
|
||||||
let result = try api.getClientPointer()
|
|
||||||
reply(wrapResult(result))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
getClientPointerChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
setRequestHeadersChannel.setMessageHandler { message, reply in
|
|
||||||
let args = message as! [Any?]
|
|
||||||
let headersArg = args[0] as! [String: String]
|
|
||||||
let serverUrlsArg = args[1] as! [String]
|
|
||||||
let tokenArg: String? = nilOrValue(args[2])
|
|
||||||
do {
|
|
||||||
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
|
|
||||||
reply(wrapResult(nil))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRequestHeadersChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import native_video_player
|
|
||||||
|
|
||||||
enum ImportError: Error {
|
enum ImportError: Error {
|
||||||
case noFile
|
case noFile
|
||||||
@@ -17,26 +16,15 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
|
||||||
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
||||||
self?.activeImporter = nil
|
self?.activeImporter = nil
|
||||||
completion(result)
|
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
|
||||||
}, viewController: viewController)
|
}, viewController: viewController)
|
||||||
activeImporter = importer
|
activeImporter = importer
|
||||||
importer.load()
|
importer.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasCertificate() throws -> Bool {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassIdentity,
|
|
||||||
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
|
||||||
kSecReturnRef as String: true,
|
|
||||||
]
|
|
||||||
var item: CFTypeRef?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
||||||
return status == errSecSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
let status = clearCerts()
|
let status = clearCerts()
|
||||||
if status == errSecSuccess || status == errSecItemNotFound {
|
if status == errSecSuccess || status == errSecItemNotFound {
|
||||||
@@ -52,55 +40,14 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
}
|
}
|
||||||
completion(.failure(ImportError.keychainError(status)))
|
completion(.failure(ImportError.keychainError(status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getClientPointer() throws -> Int64 {
|
|
||||||
let pointer = URLSessionManager.shared.sessionPointer
|
|
||||||
return Int64(Int(bitPattern: pointer))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
|
|
||||||
URLSessionManager.setServerUrls(serverUrls)
|
|
||||||
|
|
||||||
if let token = token {
|
|
||||||
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
|
|
||||||
for serverUrl in serverUrls {
|
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
|
||||||
let values: [AuthCookie: String] = [
|
|
||||||
.accessToken: token,
|
|
||||||
.isAuthenticated: "true",
|
|
||||||
.authType: "password",
|
|
||||||
]
|
|
||||||
for (cookie, value) in values {
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
|
||||||
.name: cookie.name,
|
|
||||||
.value: value,
|
|
||||||
.domain: domain,
|
|
||||||
.path: "/",
|
|
||||||
.expires: expiry,
|
|
||||||
]
|
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
|
||||||
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
|
||||||
if let httpCookie = HTTPCookie(properties: properties) {
|
|
||||||
URLSessionManager.cookieStorage.setCookie(httpCookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
|
||||||
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
|
||||||
URLSessionManager.shared.recreateSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
||||||
private let promptText: ClientCertPrompt
|
private let promptText: ClientCertPrompt
|
||||||
private var completion: ((Result<Void, Error>) -> Void)
|
private var completion: ((Result<(Data, String), Error>) -> Void)
|
||||||
private weak var viewController: UIViewController?
|
private weak var viewController: UIViewController?
|
||||||
|
|
||||||
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
|
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
|
||||||
self.promptText = promptText
|
self.promptText = promptText
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
@@ -134,7 +81,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await URLSessionManager.shared.session.flush()
|
await URLSessionManager.shared.session.flush()
|
||||||
self.completion(.success(()))
|
self.completion(.success((data, password)))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +1,49 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import native_video_player
|
|
||||||
|
|
||||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||||
let HEADERS_KEY = "immich.request_headers"
|
|
||||||
let SERVER_URLS_KEY = "immich.server_urls"
|
|
||||||
let APP_GROUP = "group.app.immich.share"
|
|
||||||
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
|
|
||||||
|
|
||||||
enum AuthCookie: CaseIterable {
|
|
||||||
case accessToken, isAuthenticated, authType
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self {
|
|
||||||
case .accessToken: return "immich_access_token"
|
|
||||||
case .isAuthenticated: return "immich_is_authenticated"
|
|
||||||
case .authType: return "immich_auth_type"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpOnly: Bool {
|
|
||||||
switch self {
|
|
||||||
case .accessToken, .authType: return true
|
|
||||||
case .isAuthenticated: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let names: Set<String> = Set(allCases.map(\.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UserDefaults {
|
|
||||||
static let group = UserDefaults(suiteName: APP_GROUP)!
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages a shared URLSession with SSL configuration support.
|
/// Manages a shared URLSession with SSL configuration support.
|
||||||
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
|
|
||||||
class URLSessionManager: NSObject {
|
class URLSessionManager: NSObject {
|
||||||
static let shared = URLSessionManager()
|
static let shared = URLSessionManager()
|
||||||
|
|
||||||
private(set) var session: URLSession
|
let session: URLSession
|
||||||
let delegate: URLSessionManagerDelegate
|
private let configuration = {
|
||||||
private static let cacheDir: URL = {
|
let config = URLSessionConfiguration.default
|
||||||
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
||||||
|
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
||||||
.first!
|
.first!
|
||||||
.appendingPathComponent("api", isDirectory: true)
|
.appendingPathComponent("api", isDirectory: true)
|
||||||
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
return dir
|
|
||||||
}()
|
config.urlCache = URLCache(
|
||||||
private static let urlCache = URLCache(
|
|
||||||
memoryCapacity: 0,
|
memoryCapacity: 0,
|
||||||
diskCapacity: 1024 * 1024 * 1024,
|
diskCapacity: 1024 * 1024 * 1024,
|
||||||
directory: cacheDir
|
directory: cacheDir
|
||||||
)
|
)
|
||||||
static let userAgent: String = {
|
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
|
||||||
return "Immich_iOS_\(version)"
|
|
||||||
}()
|
|
||||||
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
|
|
||||||
private static var serverUrls: [String] = []
|
|
||||||
private static var isSyncing = false
|
|
||||||
|
|
||||||
var sessionPointer: UnsafeMutableRawPointer {
|
|
||||||
Unmanaged.passUnretained(session).toOpaque()
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
delegate = URLSessionManagerDelegate()
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
super.init()
|
|
||||||
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
Self.self,
|
|
||||||
selector: #selector(Self.cookiesDidChange),
|
|
||||||
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
|
|
||||||
object: Self.cookieStorage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recreateSession() {
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setServerUrls(_ urls: [String]) {
|
|
||||||
guard urls != serverUrls else { return }
|
|
||||||
serverUrls = urls
|
|
||||||
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
|
|
||||||
syncAuthCookies()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private static func cookiesDidChange(_ notification: Notification) {
|
|
||||||
guard !isSyncing, !serverUrls.isEmpty else { return }
|
|
||||||
syncAuthCookies()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func syncAuthCookies() {
|
|
||||||
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
|
|
||||||
let allCookies = cookieStorage.cookies ?? []
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
let serverAuthCookies = allCookies.filter {
|
|
||||||
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceCookies: [String: HTTPCookie] = [:]
|
|
||||||
for cookie in serverAuthCookies {
|
|
||||||
if cookie.expiresDate.map({ $0 > now }) ?? true {
|
|
||||||
sourceCookies[cookie.name] = cookie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncing = true
|
|
||||||
defer { isSyncing = false }
|
|
||||||
|
|
||||||
if sourceCookies.isEmpty {
|
|
||||||
for cookie in serverAuthCookies {
|
|
||||||
cookieStorage.deleteCookie(cookie)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for serverUrl in serverUrls {
|
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
|
||||||
|
|
||||||
for (_, source) in sourceCookies {
|
|
||||||
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
|
||||||
.name: source.name,
|
|
||||||
.value: source.value,
|
|
||||||
.domain: domain,
|
|
||||||
.path: "/",
|
|
||||||
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
|
|
||||||
]
|
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
|
||||||
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
|
|
||||||
|
|
||||||
if let cookie = HTTPCookie(properties: properties) {
|
|
||||||
cookieStorage.setCookie(cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
|
||||||
let config = URLSessionConfiguration.default
|
|
||||||
config.urlCache = urlCache
|
|
||||||
config.httpCookieStorage = cookieStorage
|
|
||||||
config.httpMaximumConnectionsPerHost = 64
|
config.httpMaximumConnectionsPerHost = 64
|
||||||
config.timeoutIntervalForRequest = 60
|
config.timeoutIntervalForRequest = 60
|
||||||
config.timeoutIntervalForResource = 300
|
config.timeoutIntervalForResource = 300
|
||||||
|
|
||||||
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
|
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
||||||
config.httpAdditionalHeaders = headers
|
|
||||||
|
|
||||||
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Patches background_downloader's URLSession to use shared auth configuration.
|
|
||||||
/// Must be called before background_downloader creates its session (i.e. early in app startup).
|
|
||||||
static func patchBackgroundDownloader() {
|
|
||||||
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
|
|
||||||
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
|
|
||||||
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
|
|
||||||
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
|
|
||||||
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
|
|
||||||
method_exchangeImplementations(original, swizzled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add auth challenge handling to background_downloader's UrlSessionDelegate
|
|
||||||
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
|
|
||||||
|
|
||||||
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
|
|
||||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
|
||||||
= { _, session, challenge, completion in
|
|
||||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
|
|
||||||
}
|
|
||||||
class_replaceMethod(targetClass,
|
|
||||||
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
|
|
||||||
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
|
|
||||||
|
|
||||||
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
|
|
||||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
|
||||||
= { _, session, task, challenge, completion in
|
|
||||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
|
|
||||||
}
|
|
||||||
class_replaceMethod(targetClass,
|
|
||||||
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
|
|
||||||
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension URLSessionConfiguration {
|
|
||||||
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
|
|
||||||
// After swizzle, this calls the original implementation
|
|
||||||
let config = immich_background(withIdentifier: id)
|
|
||||||
config.httpCookieStorage = URLSessionManager.cookieStorage
|
|
||||||
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
|
|
||||||
return config
|
return config
|
||||||
|
}()
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
|
||||||
|
super.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler)
|
handleChallenge(challenge, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
@@ -218,24 +52,20 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler, task: task)
|
handleChallenge(challenge, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChallenge(
|
func handleChallenge(
|
||||||
_ session: URLSession,
|
|
||||||
_ challenge: URLAuthenticationChallenge,
|
_ challenge: URLAuthenticationChallenge,
|
||||||
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
task: URLSessionTask? = nil
|
|
||||||
) {
|
) {
|
||||||
switch challenge.protectionSpace.authenticationMethod {
|
switch challenge.protectionSpace.authenticationMethod {
|
||||||
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
|
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
|
||||||
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
|
|
||||||
default: completionHandler(.performDefaultHandling, nil)
|
default: completionHandler(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleClientCertificate(
|
private func handleClientCertificate(
|
||||||
_ session: URLSession,
|
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -250,29 +80,8 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
let credential = URLCredential(identity: identity as! SecIdentity,
|
let credential = URLCredential(identity: identity as! SecIdentity,
|
||||||
certificates: nil,
|
certificates: nil,
|
||||||
persistence: .forSession)
|
persistence: .forSession)
|
||||||
if #available(iOS 15, *) {
|
|
||||||
VideoProxyServer.shared.session = session
|
|
||||||
}
|
|
||||||
return completion(.useCredential, credential)
|
return completion(.useCredential, credential)
|
||||||
}
|
}
|
||||||
completion(.performDefaultHandling, nil)
|
completion(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBasicAuth(
|
|
||||||
_ session: URLSession,
|
|
||||||
task: URLSessionTask?,
|
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
|
||||||
) {
|
|
||||||
guard let url = task?.originalRequest?.url,
|
|
||||||
let user = url.user,
|
|
||||||
let password = url.password
|
|
||||||
else {
|
|
||||||
return completion(.performDefaultHandling, nil)
|
|
||||||
}
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
VideoProxyServer.shared.session = session
|
|
||||||
}
|
|
||||||
let credential = URLCredential(user: user, password: password, persistence: .forSession)
|
|
||||||
completion(.useCredential, credential)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol RemoteImageApi {
|
protocol RemoteImageApi {
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -86,9 +86,10 @@ class RemoteImageApiSetup {
|
|||||||
requestImageChannel.setMessageHandler { message, reply in
|
requestImageChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let urlArg = args[0] as! String
|
let urlArg = args[0] as! String
|
||||||
let requestIdArg = args[1] as! Int64
|
let headersArg = args[1] as! [String: String]
|
||||||
let preferEncodedArg = args[2] as! Bool
|
let requestIdArg = args[2] as! Int64
|
||||||
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
let preferEncodedArg = args[3] as! Bool
|
||||||
|
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
] as CFDictionary
|
||||||
|
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
|
for (key, value) in headers {
|
||||||
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
|
}
|
||||||
|
|
||||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
||||||
|
|||||||
@@ -357,12 +357,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
completionHandler: { error in
|
completionHandler: { error in
|
||||||
let result: HashResult? = switch (error) {
|
let result: HashResult? = switch (error) {
|
||||||
case let e as PHPhotosError where e.code == .userCancelled: nil
|
case let e as PHPhotosError where e.code == .userCancelled: nil
|
||||||
case let e as PHPhotosError where e.code == .networkAccessRequired:
|
|
||||||
HashResult(
|
|
||||||
assetId: asset.localIdentifier,
|
|
||||||
error: "ICLOUD_ONLY",
|
|
||||||
hash: nil
|
|
||||||
)
|
|
||||||
case let .some(e): HashResult(
|
case let .some(e): HashResult(
|
||||||
assetId: asset.localIdentifier,
|
assetId: asset.localIdentifier,
|
||||||
error: "Failed to hash asset: \(e.localizedDescription)",
|
error: "Failed to hash asset: \(e.localizedDescription)",
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
|
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||||
const Color red400 = Color(0xFFEF5350);
|
const Color red400 = Color(0xFFEF5350);
|
||||||
const Color grey200 = Color(0xFFEEEEEE);
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ sealed class BaseAsset {
|
|||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
|
||||||
|
|
||||||
AssetPlaybackStyle get playbackStyle {
|
AssetPlaybackStyle get playbackStyle {
|
||||||
if (isVideo) return AssetPlaybackStyle.video;
|
if (isVideo) return AssetPlaybackStyle.video;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import "package:openapi/api.dart" as api show AssetEditAction;
|
|
||||||
|
|
||||||
enum AssetEditAction { rotate, crop, mirror, other }
|
|
||||||
|
|
||||||
extension AssetEditActionExtension on AssetEditAction {
|
|
||||||
api.AssetEditAction? toDto() {
|
|
||||||
return switch (this) {
|
|
||||||
AssetEditAction.rotate => api.AssetEditAction.rotate,
|
|
||||||
AssetEditAction.crop => api.AssetEditAction.crop,
|
|
||||||
AssetEditAction.mirror => api.AssetEditAction.mirror,
|
|
||||||
AssetEditAction.other => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEdit {
|
|
||||||
final AssetEditAction action;
|
|
||||||
final Map<String, dynamic> parameters;
|
|
||||||
|
|
||||||
const AssetEdit({required this.action, required this.parameters});
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
|
|
||||||
class SearchResult {
|
class SearchResult {
|
||||||
final List<BaseAsset> assets;
|
final List<BaseAsset> assets;
|
||||||
|
final double scrollOffset;
|
||||||
final int? nextPage;
|
final int? nextPage;
|
||||||
|
|
||||||
const SearchResult({required this.assets, this.nextPage});
|
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
||||||
|
|
||||||
|
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
||||||
|
return SearchResult(
|
||||||
|
assets: assets ?? this.assets,
|
||||||
|
nextPage: nextPage ?? this.nextPage,
|
||||||
|
scrollOffset: scrollOffset ?? this.scrollOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant SearchResult other) {
|
bool operator ==(covariant SearchResult other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
final listEquals = const DeepCollectionEquality().equals;
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
@@ -27,6 +28,7 @@ import 'package:immich_mobile/services/localization.service.dart';
|
|||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -62,7 +64,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
final Drift _drift;
|
final Drift _drift;
|
||||||
final DriftLogger _driftLogger;
|
final DriftLogger _driftLogger;
|
||||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||||
final _cancellationToken = Completer<void>();
|
final CancellationToken _cancellationToken = CancellationToken();
|
||||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||||
|
|
||||||
bool _isCleanedUp = false;
|
bool _isCleanedUp = false;
|
||||||
@@ -86,6 +88,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
[
|
[
|
||||||
loadTranslations(),
|
loadTranslations(),
|
||||||
@@ -194,7 +198,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_ref?.dispose();
|
_ref?.dispose();
|
||||||
_ref = null;
|
_ref = null;
|
||||||
|
|
||||||
_cancellationToken.complete();
|
_cancellationToken.cancel();
|
||||||
_logger.info("Cleaning up background worker");
|
_logger.info("Cleaning up background worker");
|
||||||
|
|
||||||
final cleanupFutures = [
|
final cleanupFutures = [
|
||||||
|
|||||||
@@ -109,11 +109,9 @@ class HashService {
|
|||||||
_log.fine("Hashing ${toHash.length} files");
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
final hashed = <String, String>{};
|
final hashed = <String, String>{};
|
||||||
// Never download from iCloud just to hash. iCloud-only assets will be
|
|
||||||
// uploaded directly and the server will compute + return their checksum.
|
|
||||||
final hashResults = await _nativeSyncApi.hashAssets(
|
final hashResults = await _nativeSyncApi.hashAssets(
|
||||||
toHash.keys.toList(),
|
toHash.keys.toList(),
|
||||||
allowNetworkAccess: false,
|
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
hashResults.length == toHash.length,
|
hashResults.length == toHash.length,
|
||||||
@@ -129,10 +127,6 @@ class HashService {
|
|||||||
final hashResult = hashResults[i];
|
final hashResult = hashResults[i];
|
||||||
if (hashResult.hash != null) {
|
if (hashResult.hash != null) {
|
||||||
hashed[hashResult.assetId] = hashResult.hash!;
|
hashed[hashResult.assetId] = hashResult.hash!;
|
||||||
} else if (hashResult.error == 'ICLOUD_ONLY') {
|
|
||||||
// Asset is in iCloud and not available locally. It will be uploaded
|
|
||||||
// directly and the server will compute its checksum.
|
|
||||||
_log.fine("Skipping iCloud-only asset ${hashResult.assetId} from album: ${album.name}");
|
|
||||||
} else {
|
} else {
|
||||||
final asset = toHash[hashResult.assetId];
|
final asset = toHash[hashResult.assetId];
|
||||||
_log.warning(
|
_log.warning(
|
||||||
|
|||||||
@@ -70,14 +70,13 @@ extension on AssetResponseDto {
|
|||||||
_ => AssetVisibility.timeline,
|
_ => AssetVisibility.timeline,
|
||||||
},
|
},
|
||||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||||
height: height?.toInt(),
|
height: exifInfo?.exifImageHeight?.toInt(),
|
||||||
width: width?.toInt(),
|
width: exifInfo?.exifImageWidth?.toInt(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
thumbHash: thumbhash,
|
thumbHash: thumbhash,
|
||||||
localId: null,
|
localId: null,
|
||||||
type: type.toAssetType(),
|
type: type.toAssetType(),
|
||||||
stackId: stack?.id,
|
|
||||||
isEdited: isEdited,
|
isEdited: isEdited,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,10 +205,6 @@ class SyncStreamService {
|
|||||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||||
case SyncEntityType.assetExifV1:
|
case SyncEntityType.assetExifV1:
|
||||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||||
case SyncEntityType.assetEditV1:
|
|
||||||
return _syncStreamRepository.updateAssetEditsV1(data.cast());
|
|
||||||
case SyncEntityType.assetEditDeleteV1:
|
|
||||||
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
|
|
||||||
case SyncEntityType.assetMetadataV1:
|
case SyncEntityType.assetMetadataV1:
|
||||||
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
||||||
case SyncEntityType.assetMetadataDeleteV1:
|
case SyncEntityType.assetMetadataDeleteV1:
|
||||||
@@ -340,43 +336,39 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
|
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
|
||||||
_logger.info('Processing AssetEditReadyV1 event');
|
if (batchData.isEmpty) return;
|
||||||
|
|
||||||
|
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
|
||||||
|
|
||||||
|
final List<SyncAssetV1> assets = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
for (final data in batchData) {
|
||||||
if (data is! Map<String, dynamic>) {
|
if (data is! Map<String, dynamic>) {
|
||||||
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final payload = data;
|
final payload = data;
|
||||||
|
final assetData = payload['asset'];
|
||||||
|
|
||||||
if (payload['asset'] == null) {
|
if (assetData == null) {
|
||||||
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = SyncAssetV1.fromJson(payload['asset']);
|
final asset = SyncAssetV1.fromJson(assetData);
|
||||||
if (asset == null) {
|
|
||||||
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
|
if (asset != null) {
|
||||||
|
assets.add(asset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SyncAssetEditV1> assetEdits = [];
|
if (assets.isNotEmpty) {
|
||||||
|
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
|
||||||
// Edits are only send on v2.6.0+
|
_logger.info('Successfully processed ${assets.length} edited assets');
|
||||||
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
|
|
||||||
assetEdits = (payload['edit'] as List<dynamic>)
|
|
||||||
.map((e) => SyncAssetEditV1.fromJson(e))
|
|
||||||
.whereType<SyncAssetEditV1>()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
|
||||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
|
||||||
|
|
||||||
_logger.info(
|
|
||||||
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
|
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ class TimelineFactory {
|
|||||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||||
|
|
||||||
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
|
|
||||||
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
|
|
||||||
|
|
||||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||||
|
|
||||||
@@ -115,7 +112,7 @@ class TimelineService {
|
|||||||
|
|
||||||
if (totalAssets == 0) {
|
if (totalAssets == 0) {
|
||||||
_bufferOffset = 0;
|
_bufferOffset = 0;
|
||||||
_buffer = [];
|
_buffer.clear();
|
||||||
} else {
|
} else {
|
||||||
final int offset;
|
final int offset;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> syncWebsocketEdit(dynamic data) {
|
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
|
||||||
if (_syncWebsocketTask != null) {
|
if (_syncWebsocketTask != null) {
|
||||||
return _syncWebsocketTask!.future;
|
return _syncWebsocketTask!.future;
|
||||||
}
|
}
|
||||||
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
|
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
|
||||||
return _syncWebsocketTask!.whenComplete(() {
|
return _syncWebsocketTask!.whenComplete(() {
|
||||||
_syncWebsocketTask = null;
|
_syncWebsocketTask = null;
|
||||||
});
|
});
|
||||||
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
|
|||||||
debugLabel: 'websocket-batch',
|
debugLabel: 'websocket-batch',
|
||||||
);
|
);
|
||||||
|
|
||||||
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
|
||||||
debugLabel: 'websocket-edit',
|
debugLabel: 'websocket-edit',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,27 +33,12 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SnapScrollController extends ScrollController {
|
|
||||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
|
|
||||||
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
|
||||||
double snapOffset;
|
|
||||||
|
|
||||||
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get shouldIgnorePointer => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapScrollPhysics extends ScrollPhysics {
|
class SnapScrollPhysics extends ScrollPhysics {
|
||||||
static const _minFlingVelocity = 700.0;
|
static const _minFlingVelocity = 700.0;
|
||||||
static const minSnapDistance = 30.0;
|
static const minSnapDistance = 30.0;
|
||||||
|
|
||||||
|
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||||
|
|
||||||
const SnapScrollPhysics({super.parent});
|
const SnapScrollPhysics({super.parent});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -81,21 +66,91 @@ class SnapScrollPhysics extends ScrollPhysics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
|
return ScrollSpringSimulation(
|
||||||
|
_spring,
|
||||||
|
position.pixels,
|
||||||
|
target(position, velocity, snapOffset),
|
||||||
|
velocity,
|
||||||
|
tolerance: toleranceFor(position),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get allowImplicitScrolling => false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get allowUserScrolling => false;
|
|
||||||
|
|
||||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||||
if (velocity > _minFlingVelocity) return snapOffset;
|
if (velocity > _minFlingVelocity) return snapOffset;
|
||||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||||
|
double snapOffset;
|
||||||
|
|
||||||
|
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyScrollController extends ScrollController {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
ProxyScrollController({required this.scrollController});
|
||||||
|
|
||||||
|
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
||||||
|
return ProxyScrollPosition(
|
||||||
|
scrollController: scrollController,
|
||||||
|
physics: physics,
|
||||||
|
context: context,
|
||||||
|
oldPosition: oldPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyScrollPosition extends SnapScrollPosition {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
ProxyScrollPosition({
|
||||||
|
required this.scrollController,
|
||||||
|
required super.physics,
|
||||||
|
required super.context,
|
||||||
|
super.oldPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
double setPixels(double newPixels) {
|
||||||
|
final overscroll = super.setPixels(newPixels);
|
||||||
|
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||||
|
scrollController.position.forcePixels(pixels);
|
||||||
|
}
|
||||||
|
return overscroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void forcePixels(double value) {
|
||||||
|
super.forcePixels(value);
|
||||||
|
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||||
|
scrollController.position.forcePixels(pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||||
|
? scrollController.position.maxScrollExtent
|
||||||
|
: super.maxScrollExtent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||||
|
? scrollController.position.minScrollExtent
|
||||||
|
: super.minScrollExtent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
||||||
|
? scrollController.position.viewportDimension
|
||||||
|
: super.viewportDimension;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
|
||||||
|
|
||||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)')
|
|
||||||
class AssetEditEntity extends Table with DriftDefaultsMixin {
|
|
||||||
const AssetEditEntity();
|
|
||||||
|
|
||||||
TextColumn get id => text()();
|
|
||||||
|
|
||||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
|
||||||
|
|
||||||
IntColumn get action => intEnum<AssetEditAction>()();
|
|
||||||
|
|
||||||
BlobColumn get parameters => blob().map(editParameterConverter)();
|
|
||||||
|
|
||||||
IntColumn get sequence => integer()();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<Column> get primaryKey => {id};
|
|
||||||
}
|
|
||||||
|
|
||||||
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
|
|
||||||
fromJson: (json) => json as Map<String, Object?>,
|
|
||||||
);
|
|
||||||
|
|
||||||
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
|
|
||||||
AssetEdit toDto() {
|
|
||||||
return AssetEdit(action: action, parameters: parameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,752 +0,0 @@
|
|||||||
// dart format width=80
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
import 'package:drift/drift.dart' as i0;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
|
||||||
as i1;
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
|
|
||||||
import 'dart:typed_data' as i3;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
|
|
||||||
as i4;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
|
||||||
as i5;
|
|
||||||
import 'package:drift/internal/modular.dart' as i6;
|
|
||||||
|
|
||||||
typedef $$AssetEditEntityTableCreateCompanionBuilder =
|
|
||||||
i1.AssetEditEntityCompanion Function({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
});
|
|
||||||
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
|
|
||||||
i1.AssetEditEntityCompanion Function({
|
|
||||||
i0.Value<String> id,
|
|
||||||
i0.Value<String> assetId,
|
|
||||||
i0.Value<i2.AssetEditAction> action,
|
|
||||||
i0.Value<Map<String, Object?>> parameters,
|
|
||||||
i0.Value<int> sequence,
|
|
||||||
});
|
|
||||||
|
|
||||||
final class $$AssetEditEntityTableReferences
|
|
||||||
extends
|
|
||||||
i0.BaseReferences<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData
|
|
||||||
> {
|
|
||||||
$$AssetEditEntityTableReferences(
|
|
||||||
super.$_db,
|
|
||||||
super.$_table,
|
|
||||||
super.$_typedResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
|
||||||
i6.ReadDatabaseContainer(db)
|
|
||||||
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
|
|
||||||
.createAlias(
|
|
||||||
i0.$_aliasNameGenerator(
|
|
||||||
i6.ReadDatabaseContainer(db)
|
|
||||||
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
|
|
||||||
.assetId,
|
|
||||||
i6.ReadDatabaseContainer(
|
|
||||||
db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
|
||||||
final $_column = $_itemColumn<String>('asset_id')!;
|
|
||||||
|
|
||||||
final manager = i5
|
|
||||||
.$$RemoteAssetEntityTableTableManager(
|
|
||||||
$_db,
|
|
||||||
i6.ReadDatabaseContainer(
|
|
||||||
$_db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
)
|
|
||||||
.filter((f) => f.id.sqlEquals($_column));
|
|
||||||
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
|
|
||||||
if (item == null) return manager;
|
|
||||||
return i0.ProcessedTableManager(
|
|
||||||
manager.$state.copyWith(prefetchedData: [item]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableFilterComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableFilterComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.ColumnFilters<String> get id => $composableBuilder(
|
|
||||||
column: $table.id,
|
|
||||||
builder: (column) => i0.ColumnFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
|
|
||||||
get action => $composableBuilder(
|
|
||||||
column: $table.action,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<
|
|
||||||
Map<String, Object?>,
|
|
||||||
Map<String, Object>,
|
|
||||||
i3.Uint8List
|
|
||||||
>
|
|
||||||
get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnFilters<int> get sequence => $composableBuilder(
|
|
||||||
column: $table.sequence,
|
|
||||||
builder: (column) => i0.ColumnFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableFilterComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableOrderingComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableOrderingComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.ColumnOrderings<String> get id => $composableBuilder(
|
|
||||||
column: $table.id,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get action => $composableBuilder(
|
|
||||||
column: $table.action,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get sequence => $composableBuilder(
|
|
||||||
column: $table.sequence,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
|
|
||||||
$composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableAnnotationComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableAnnotationComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.GeneratedColumn<String> get id =>
|
|
||||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
|
|
||||||
$composableBuilder(column: $table.action, builder: (column) => column);
|
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
|
|
||||||
get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => column,
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.GeneratedColumn<int> get sequence =>
|
|
||||||
$composableBuilder(column: $table.sequence, builder: (column) => column);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
|
|
||||||
$composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableTableManager
|
|
||||||
extends
|
|
||||||
i0.RootTableManager<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer,
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer,
|
|
||||||
i1.$$AssetEditEntityTableAnnotationComposer,
|
|
||||||
$$AssetEditEntityTableCreateCompanionBuilder,
|
|
||||||
$$AssetEditEntityTableUpdateCompanionBuilder,
|
|
||||||
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i0.PrefetchHooks Function({bool assetId})
|
|
||||||
> {
|
|
||||||
$$AssetEditEntityTableTableManager(
|
|
||||||
i0.GeneratedDatabase db,
|
|
||||||
i1.$AssetEditEntityTable table,
|
|
||||||
) : super(
|
|
||||||
i0.TableManagerState(
|
|
||||||
db: db,
|
|
||||||
table: table,
|
|
||||||
createFilteringComposer: () =>
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
|
|
||||||
createOrderingComposer: () =>
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
|
|
||||||
createComputedFieldComposer: () => i1
|
|
||||||
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
|
|
||||||
updateCompanionCallback:
|
|
||||||
({
|
|
||||||
i0.Value<String> id = const i0.Value.absent(),
|
|
||||||
i0.Value<String> assetId = const i0.Value.absent(),
|
|
||||||
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
|
|
||||||
i0.Value<Map<String, Object?>> parameters =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
i0.Value<int> sequence = const i0.Value.absent(),
|
|
||||||
}) => i1.AssetEditEntityCompanion(
|
|
||||||
id: id,
|
|
||||||
assetId: assetId,
|
|
||||||
action: action,
|
|
||||||
parameters: parameters,
|
|
||||||
sequence: sequence,
|
|
||||||
),
|
|
||||||
createCompanionCallback:
|
|
||||||
({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
}) => i1.AssetEditEntityCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
assetId: assetId,
|
|
||||||
action: action,
|
|
||||||
parameters: parameters,
|
|
||||||
sequence: sequence,
|
|
||||||
),
|
|
||||||
withReferenceMapper: (p0) => p0
|
|
||||||
.map(
|
|
||||||
(e) => (
|
|
||||||
e.readTable(table),
|
|
||||||
i1.$$AssetEditEntityTableReferences(db, table, e),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
prefetchHooksCallback: ({assetId = false}) {
|
|
||||||
return i0.PrefetchHooks(
|
|
||||||
db: db,
|
|
||||||
explicitlyWatchedTables: [],
|
|
||||||
addJoins:
|
|
||||||
<
|
|
||||||
T extends i0.TableManagerState<
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic
|
|
||||||
>
|
|
||||||
>(state) {
|
|
||||||
if (assetId) {
|
|
||||||
state =
|
|
||||||
state.withJoin(
|
|
||||||
currentTable: table,
|
|
||||||
currentColumn: table.assetId,
|
|
||||||
referencedTable: i1
|
|
||||||
.$$AssetEditEntityTableReferences
|
|
||||||
._assetIdTable(db),
|
|
||||||
referencedColumn: i1
|
|
||||||
.$$AssetEditEntityTableReferences
|
|
||||||
._assetIdTable(db)
|
|
||||||
.id,
|
|
||||||
)
|
|
||||||
as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
getPrefetchedDataCallback: (items) async {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef $$AssetEditEntityTableProcessedTableManager =
|
|
||||||
i0.ProcessedTableManager<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer,
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer,
|
|
||||||
i1.$$AssetEditEntityTableAnnotationComposer,
|
|
||||||
$$AssetEditEntityTableCreateCompanionBuilder,
|
|
||||||
$$AssetEditEntityTableUpdateCompanionBuilder,
|
|
||||||
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i0.PrefetchHooks Function({bool assetId})
|
|
||||||
>;
|
|
||||||
i0.Index get idxAssetEditAssetId => i0.Index(
|
|
||||||
'idx_asset_edit_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
|
||||||
);
|
|
||||||
|
|
||||||
class $AssetEditEntityTable extends i4.AssetEditEntity
|
|
||||||
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
|
|
||||||
@override
|
|
||||||
final i0.GeneratedDatabase attachedDatabase;
|
|
||||||
final String? _alias;
|
|
||||||
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
|
|
||||||
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
|
|
||||||
'id',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.string,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
);
|
|
||||||
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
|
|
||||||
'assetId',
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
|
|
||||||
'asset_id',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.string,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
|
||||||
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
|
|
||||||
action =
|
|
||||||
i0.GeneratedColumn<int>(
|
|
||||||
'action',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
).withConverter<i2.AssetEditAction>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumnWithTypeConverter<
|
|
||||||
Map<String, Object?>,
|
|
||||||
i3.Uint8List
|
|
||||||
>
|
|
||||||
parameters =
|
|
||||||
i0.GeneratedColumn<i3.Uint8List>(
|
|
||||||
'parameters',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.blob,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
).withConverter<Map<String, Object?>>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters,
|
|
||||||
);
|
|
||||||
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
|
|
||||||
'sequence',
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
|
|
||||||
'sequence',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
|
||||||
id,
|
|
||||||
assetId,
|
|
||||||
action,
|
|
||||||
parameters,
|
|
||||||
sequence,
|
|
||||||
];
|
|
||||||
@override
|
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
|
||||||
@override
|
|
||||||
String get actualTableName => $name;
|
|
||||||
static const String $name = 'asset_edit_entity';
|
|
||||||
@override
|
|
||||||
i0.VerificationContext validateIntegrity(
|
|
||||||
i0.Insertable<i1.AssetEditEntityData> instance, {
|
|
||||||
bool isInserting = false,
|
|
||||||
}) {
|
|
||||||
final context = i0.VerificationContext();
|
|
||||||
final data = instance.toColumns(true);
|
|
||||||
if (data.containsKey('id')) {
|
|
||||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_idMeta);
|
|
||||||
}
|
|
||||||
if (data.containsKey('asset_id')) {
|
|
||||||
context.handle(
|
|
||||||
_assetIdMeta,
|
|
||||||
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
|
|
||||||
);
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_assetIdMeta);
|
|
||||||
}
|
|
||||||
if (data.containsKey('sequence')) {
|
|
||||||
context.handle(
|
|
||||||
_sequenceMeta,
|
|
||||||
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
|
|
||||||
);
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_sequenceMeta);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<i0.GeneratedColumn> get $primaryKey => {id};
|
|
||||||
@override
|
|
||||||
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
|
||||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
|
||||||
return i1.AssetEditEntityData(
|
|
||||||
id: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.string,
|
|
||||||
data['${effectivePrefix}id'],
|
|
||||||
)!,
|
|
||||||
assetId: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.string,
|
|
||||||
data['${effectivePrefix}asset_id'],
|
|
||||||
)!,
|
|
||||||
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}action'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.blob,
|
|
||||||
data['${effectivePrefix}parameters'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
sequence: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}sequence'],
|
|
||||||
)!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
$AssetEditEntityTable createAlias(String alias) {
|
|
||||||
return $AssetEditEntityTable(attachedDatabase, alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
|
|
||||||
const i0.EnumIndexConverter<i2.AssetEditAction>(
|
|
||||||
i2.AssetEditAction.values,
|
|
||||||
);
|
|
||||||
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
|
|
||||||
$converterparameters = i4.editParameterConverter;
|
|
||||||
@override
|
|
||||||
bool get withoutRowId => true;
|
|
||||||
@override
|
|
||||||
bool get isStrict => true;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEditEntityData extends i0.DataClass
|
|
||||||
implements i0.Insertable<i1.AssetEditEntityData> {
|
|
||||||
final String id;
|
|
||||||
final String assetId;
|
|
||||||
final i2.AssetEditAction action;
|
|
||||||
final Map<String, Object?> parameters;
|
|
||||||
final int sequence;
|
|
||||||
const AssetEditEntityData({
|
|
||||||
required this.id,
|
|
||||||
required this.assetId,
|
|
||||||
required this.action,
|
|
||||||
required this.parameters,
|
|
||||||
required this.sequence,
|
|
||||||
});
|
|
||||||
@override
|
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
|
||||||
final map = <String, i0.Expression>{};
|
|
||||||
map['id'] = i0.Variable<String>(id);
|
|
||||||
map['asset_id'] = i0.Variable<String>(assetId);
|
|
||||||
{
|
|
||||||
map['action'] = i0.Variable<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toSql(action),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
map['parameters'] = i0.Variable<i3.Uint8List>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
map['sequence'] = i0.Variable<int>(sequence);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory AssetEditEntityData.fromJson(
|
|
||||||
Map<String, dynamic> json, {
|
|
||||||
i0.ValueSerializer? serializer,
|
|
||||||
}) {
|
|
||||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
|
||||||
return AssetEditEntityData(
|
|
||||||
id: serializer.fromJson<String>(json['id']),
|
|
||||||
assetId: serializer.fromJson<String>(json['assetId']),
|
|
||||||
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
|
|
||||||
serializer.fromJson<int>(json['action']),
|
|
||||||
),
|
|
||||||
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
|
|
||||||
serializer.fromJson<Object?>(json['parameters']),
|
|
||||||
),
|
|
||||||
sequence: serializer.fromJson<int>(json['sequence']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
|
||||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
|
||||||
return <String, dynamic>{
|
|
||||||
'id': serializer.toJson<String>(id),
|
|
||||||
'assetId': serializer.toJson<String>(assetId),
|
|
||||||
'action': serializer.toJson<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toJson(action),
|
|
||||||
),
|
|
||||||
'parameters': serializer.toJson<Object?>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
|
|
||||||
),
|
|
||||||
'sequence': serializer.toJson<int>(sequence),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
i1.AssetEditEntityData copyWith({
|
|
||||||
String? id,
|
|
||||||
String? assetId,
|
|
||||||
i2.AssetEditAction? action,
|
|
||||||
Map<String, Object?>? parameters,
|
|
||||||
int? sequence,
|
|
||||||
}) => i1.AssetEditEntityData(
|
|
||||||
id: id ?? this.id,
|
|
||||||
assetId: assetId ?? this.assetId,
|
|
||||||
action: action ?? this.action,
|
|
||||||
parameters: parameters ?? this.parameters,
|
|
||||||
sequence: sequence ?? this.sequence,
|
|
||||||
);
|
|
||||||
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
|
|
||||||
return AssetEditEntityData(
|
|
||||||
id: data.id.present ? data.id.value : this.id,
|
|
||||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
|
||||||
action: data.action.present ? data.action.value : this.action,
|
|
||||||
parameters: data.parameters.present
|
|
||||||
? data.parameters.value
|
|
||||||
: this.parameters,
|
|
||||||
sequence: data.sequence.present ? data.sequence.value : this.sequence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return (StringBuffer('AssetEditEntityData(')
|
|
||||||
..write('id: $id, ')
|
|
||||||
..write('assetId: $assetId, ')
|
|
||||||
..write('action: $action, ')
|
|
||||||
..write('parameters: $parameters, ')
|
|
||||||
..write('sequence: $sequence')
|
|
||||||
..write(')'))
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
(other is i1.AssetEditEntityData &&
|
|
||||||
other.id == this.id &&
|
|
||||||
other.assetId == this.assetId &&
|
|
||||||
other.action == this.action &&
|
|
||||||
other.parameters == this.parameters &&
|
|
||||||
other.sequence == this.sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEditEntityCompanion
|
|
||||||
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
|
|
||||||
final i0.Value<String> id;
|
|
||||||
final i0.Value<String> assetId;
|
|
||||||
final i0.Value<i2.AssetEditAction> action;
|
|
||||||
final i0.Value<Map<String, Object?>> parameters;
|
|
||||||
final i0.Value<int> sequence;
|
|
||||||
const AssetEditEntityCompanion({
|
|
||||||
this.id = const i0.Value.absent(),
|
|
||||||
this.assetId = const i0.Value.absent(),
|
|
||||||
this.action = const i0.Value.absent(),
|
|
||||||
this.parameters = const i0.Value.absent(),
|
|
||||||
this.sequence = const i0.Value.absent(),
|
|
||||||
});
|
|
||||||
AssetEditEntityCompanion.insert({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
}) : id = i0.Value(id),
|
|
||||||
assetId = i0.Value(assetId),
|
|
||||||
action = i0.Value(action),
|
|
||||||
parameters = i0.Value(parameters),
|
|
||||||
sequence = i0.Value(sequence);
|
|
||||||
static i0.Insertable<i1.AssetEditEntityData> custom({
|
|
||||||
i0.Expression<String>? id,
|
|
||||||
i0.Expression<String>? assetId,
|
|
||||||
i0.Expression<int>? action,
|
|
||||||
i0.Expression<i3.Uint8List>? parameters,
|
|
||||||
i0.Expression<int>? sequence,
|
|
||||||
}) {
|
|
||||||
return i0.RawValuesInsertable({
|
|
||||||
if (id != null) 'id': id,
|
|
||||||
if (assetId != null) 'asset_id': assetId,
|
|
||||||
if (action != null) 'action': action,
|
|
||||||
if (parameters != null) 'parameters': parameters,
|
|
||||||
if (sequence != null) 'sequence': sequence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
i1.AssetEditEntityCompanion copyWith({
|
|
||||||
i0.Value<String>? id,
|
|
||||||
i0.Value<String>? assetId,
|
|
||||||
i0.Value<i2.AssetEditAction>? action,
|
|
||||||
i0.Value<Map<String, Object?>>? parameters,
|
|
||||||
i0.Value<int>? sequence,
|
|
||||||
}) {
|
|
||||||
return i1.AssetEditEntityCompanion(
|
|
||||||
id: id ?? this.id,
|
|
||||||
assetId: assetId ?? this.assetId,
|
|
||||||
action: action ?? this.action,
|
|
||||||
parameters: parameters ?? this.parameters,
|
|
||||||
sequence: sequence ?? this.sequence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
|
||||||
final map = <String, i0.Expression>{};
|
|
||||||
if (id.present) {
|
|
||||||
map['id'] = i0.Variable<String>(id.value);
|
|
||||||
}
|
|
||||||
if (assetId.present) {
|
|
||||||
map['asset_id'] = i0.Variable<String>(assetId.value);
|
|
||||||
}
|
|
||||||
if (action.present) {
|
|
||||||
map['action'] = i0.Variable<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parameters.present) {
|
|
||||||
map['parameters'] = i0.Variable<i3.Uint8List>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sequence.present) {
|
|
||||||
map['sequence'] = i0.Variable<int>(sequence.value);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return (StringBuffer('AssetEditEntityCompanion(')
|
|
||||||
..write('id: $id, ')
|
|
||||||
..write('assetId: $assetId, ')
|
|
||||||
..write('action: $action, ')
|
|
||||||
..write('parameters: $parameters, ')
|
|
||||||
..write('sequence: $sequence')
|
|
||||||
..write(')'))
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,9 @@ part of 'image_request.dart';
|
|||||||
|
|
||||||
class RemoteImageRequest extends ImageRequest {
|
class RemoteImageRequest extends ImageRequest {
|
||||||
final String uri;
|
final String uri;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
|
||||||
RemoteImageRequest({required this.uri});
|
RemoteImageRequest({required this.uri, required this.headers});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
@@ -11,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
|
||||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||||
final frame = switch (info) {
|
final frame = switch (info) {
|
||||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||||
@@ -28,7 +29,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
|
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
|
||||||
if (info == null) return null;
|
if (info == null) return null;
|
||||||
|
|
||||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/db.interface.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';
|
||||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
@@ -25,10 +24,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
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.steps.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||||
import 'package:isar/isar.dart' hide Index;
|
import 'package:isar/isar.dart' hide Index;
|
||||||
|
|
||||||
|
import 'db.repository.drift.dart';
|
||||||
|
|
||||||
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
|
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
|
||||||
// ref: isar/isar_common.dart
|
// ref: isar/isar_common.dart
|
||||||
const Symbol _kzoneTxn = #zoneTxn;
|
const Symbol _kzoneTxn = #zoneTxn;
|
||||||
@@ -66,7 +66,6 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
|||||||
AssetFaceEntity,
|
AssetFaceEntity,
|
||||||
StoreEntity,
|
StoreEntity,
|
||||||
TrashedLocalAssetEntity,
|
TrashedLocalAssetEntity,
|
||||||
AssetEditEntity,
|
|
||||||
],
|
],
|
||||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||||
)
|
)
|
||||||
@@ -98,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 22;
|
int get schemaVersion => 21;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -235,10 +234,6 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
||||||
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
||||||
},
|
},
|
||||||
from21To22: (m, v22) async {
|
|
||||||
await m.createTable(v22.assetEditEntity);
|
|
||||||
await m.createIndex(v22.idxAssetEditAssetId);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+4
-19
@@ -41,11 +41,9 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
|||||||
as i19;
|
as i19;
|
||||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||||
as i20;
|
as i20;
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
|
||||||
as i21;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||||
as i22;
|
as i21;
|
||||||
import 'package:drift/internal/modular.dart' as i23;
|
import 'package:drift/internal/modular.dart' as i22;
|
||||||
|
|
||||||
abstract class $Drift extends i0.GeneratedDatabase {
|
abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
$Drift(i0.QueryExecutor e) : super(e);
|
$Drift(i0.QueryExecutor e) : super(e);
|
||||||
@@ -87,11 +85,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
||||||
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
||||||
.$TrashedLocalAssetEntityTable(this);
|
.$TrashedLocalAssetEntityTable(this);
|
||||||
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
|
||||||
.$AssetEditEntityTable(this);
|
|
||||||
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
|
|
||||||
this,
|
this,
|
||||||
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
|
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
|
||||||
@override
|
@override
|
||||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||||
@@ -129,7 +125,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
assetFaceEntity,
|
assetFaceEntity,
|
||||||
storeEntity,
|
storeEntity,
|
||||||
trashedLocalAssetEntity,
|
trashedLocalAssetEntity,
|
||||||
assetEditEntity,
|
|
||||||
i10.idxPartnerSharedWithId,
|
i10.idxPartnerSharedWithId,
|
||||||
i11.idxLatLng,
|
i11.idxLatLng,
|
||||||
i12.idxRemoteAlbumAssetAlbumAsset,
|
i12.idxRemoteAlbumAssetAlbumAsset,
|
||||||
@@ -139,7 +134,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
i18.idxAssetFaceAssetId,
|
i18.idxAssetFaceAssetId,
|
||||||
i20.idxTrashedLocalAssetChecksum,
|
i20.idxTrashedLocalAssetChecksum,
|
||||||
i20.idxTrashedLocalAssetAlbum,
|
i20.idxTrashedLocalAssetAlbum,
|
||||||
i21.idxAssetEditAssetId,
|
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
i0.StreamQueryUpdateRules
|
i0.StreamQueryUpdateRules
|
||||||
@@ -331,13 +325,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
),
|
),
|
||||||
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
|
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
|
||||||
),
|
),
|
||||||
i0.WritePropagation(
|
|
||||||
on: i0.TableUpdateQuery.onTableName(
|
|
||||||
'remote_asset_entity',
|
|
||||||
limitUpdateKind: i0.UpdateKind.delete,
|
|
||||||
),
|
|
||||||
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
@override
|
@override
|
||||||
i0.DriftDatabaseOptions get options =>
|
i0.DriftDatabaseOptions get options =>
|
||||||
@@ -397,6 +384,4 @@ class $DriftManager {
|
|||||||
_db,
|
_db,
|
||||||
_db.trashedLocalAssetEntity,
|
_db.trashedLocalAssetEntity,
|
||||||
);
|
);
|
||||||
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
|
||||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9489,565 +9489,6 @@ class Shape31 extends i0.VersionedTable {
|
|||||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Schema22 extends i0.VersionedSchema {
|
|
||||||
Schema22({required super.database}) : super(version: 22);
|
|
||||||
@override
|
|
||||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
|
||||||
userEntity,
|
|
||||||
remoteAssetEntity,
|
|
||||||
stackEntity,
|
|
||||||
localAssetEntity,
|
|
||||||
remoteAlbumEntity,
|
|
||||||
localAlbumEntity,
|
|
||||||
localAlbumAssetEntity,
|
|
||||||
idxLocalAlbumAssetAlbumAsset,
|
|
||||||
idxRemoteAlbumOwnerId,
|
|
||||||
idxLocalAssetChecksum,
|
|
||||||
idxLocalAssetCloudId,
|
|
||||||
idxStackPrimaryAssetId,
|
|
||||||
idxRemoteAssetOwnerChecksum,
|
|
||||||
uQRemoteAssetsOwnerChecksum,
|
|
||||||
uQRemoteAssetsOwnerLibraryChecksum,
|
|
||||||
idxRemoteAssetChecksum,
|
|
||||||
idxRemoteAssetStackId,
|
|
||||||
idxRemoteAssetLocalDateTimeDay,
|
|
||||||
idxRemoteAssetLocalDateTimeMonth,
|
|
||||||
authUserEntity,
|
|
||||||
userMetadataEntity,
|
|
||||||
partnerEntity,
|
|
||||||
remoteExifEntity,
|
|
||||||
remoteAlbumAssetEntity,
|
|
||||||
remoteAlbumUserEntity,
|
|
||||||
remoteAssetCloudIdEntity,
|
|
||||||
memoryEntity,
|
|
||||||
memoryAssetEntity,
|
|
||||||
personEntity,
|
|
||||||
assetFaceEntity,
|
|
||||||
storeEntity,
|
|
||||||
trashedLocalAssetEntity,
|
|
||||||
assetEditEntity,
|
|
||||||
idxPartnerSharedWithId,
|
|
||||||
idxLatLng,
|
|
||||||
idxRemoteAlbumAssetAlbumAsset,
|
|
||||||
idxRemoteAssetCloudId,
|
|
||||||
idxPersonOwnerId,
|
|
||||||
idxAssetFacePersonId,
|
|
||||||
idxAssetFaceAssetId,
|
|
||||||
idxTrashedLocalAssetChecksum,
|
|
||||||
idxTrashedLocalAssetAlbum,
|
|
||||||
idxAssetEditAssetId,
|
|
||||||
];
|
|
||||||
late final Shape20 userEntity = Shape20(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'user_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_1,
|
|
||||||
_column_3,
|
|
||||||
_column_84,
|
|
||||||
_column_85,
|
|
||||||
_column_91,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape28 remoteAssetEntity = Shape28(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_1,
|
|
||||||
_column_8,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_10,
|
|
||||||
_column_11,
|
|
||||||
_column_12,
|
|
||||||
_column_0,
|
|
||||||
_column_13,
|
|
||||||
_column_14,
|
|
||||||
_column_15,
|
|
||||||
_column_16,
|
|
||||||
_column_17,
|
|
||||||
_column_18,
|
|
||||||
_column_19,
|
|
||||||
_column_20,
|
|
||||||
_column_21,
|
|
||||||
_column_86,
|
|
||||||
_column_101,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape3 stackEntity = Shape3(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'stack_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape30 localAssetEntity = Shape30(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'local_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_1,
|
|
||||||
_column_8,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_10,
|
|
||||||
_column_11,
|
|
||||||
_column_12,
|
|
||||||
_column_0,
|
|
||||||
_column_22,
|
|
||||||
_column_14,
|
|
||||||
_column_23,
|
|
||||||
_column_98,
|
|
||||||
_column_96,
|
|
||||||
_column_46,
|
|
||||||
_column_47,
|
|
||||||
_column_103,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape9 remoteAlbumEntity = Shape9(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_album_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_1,
|
|
||||||
_column_56,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_15,
|
|
||||||
_column_57,
|
|
||||||
_column_58,
|
|
||||||
_column_59,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape19 localAlbumEntity = Shape19(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'local_album_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_1,
|
|
||||||
_column_5,
|
|
||||||
_column_31,
|
|
||||||
_column_32,
|
|
||||||
_column_90,
|
|
||||||
_column_33,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'local_album_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
|
||||||
columns: [_column_34, _column_35, _column_33],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
|
||||||
'idx_local_album_asset_album_asset',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
|
||||||
'idx_remote_album_owner_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
|
||||||
'idx_local_asset_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
|
||||||
'idx_local_asset_cloud_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
|
||||||
'idx_stack_primary_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
|
||||||
'idx_remote_asset_owner_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
|
||||||
'UQ_remote_assets_owner_checksum',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
|
||||||
);
|
|
||||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
|
||||||
'UQ_remote_assets_owner_library_checksum',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
|
||||||
'idx_remote_asset_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
|
||||||
'idx_remote_asset_stack_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
|
||||||
'idx_remote_asset_local_date_time_day',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
|
||||||
'idx_remote_asset_local_date_time_month',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
|
||||||
);
|
|
||||||
late final Shape21 authUserEntity = Shape21(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'auth_user_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_1,
|
|
||||||
_column_3,
|
|
||||||
_column_2,
|
|
||||||
_column_84,
|
|
||||||
_column_85,
|
|
||||||
_column_92,
|
|
||||||
_column_93,
|
|
||||||
_column_7,
|
|
||||||
_column_94,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape4 userMetadataEntity = Shape4(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'user_metadata_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
|
||||||
columns: [_column_25, _column_26, _column_27],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape5 partnerEntity = Shape5(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'partner_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
|
||||||
columns: [_column_28, _column_29, _column_30],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape8 remoteExifEntity = Shape8(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_exif_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
|
||||||
columns: [
|
|
||||||
_column_36,
|
|
||||||
_column_37,
|
|
||||||
_column_38,
|
|
||||||
_column_39,
|
|
||||||
_column_40,
|
|
||||||
_column_41,
|
|
||||||
_column_11,
|
|
||||||
_column_10,
|
|
||||||
_column_42,
|
|
||||||
_column_43,
|
|
||||||
_column_44,
|
|
||||||
_column_45,
|
|
||||||
_column_46,
|
|
||||||
_column_47,
|
|
||||||
_column_48,
|
|
||||||
_column_49,
|
|
||||||
_column_50,
|
|
||||||
_column_51,
|
|
||||||
_column_52,
|
|
||||||
_column_53,
|
|
||||||
_column_54,
|
|
||||||
_column_55,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_album_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
|
||||||
columns: [_column_36, _column_60],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_album_user_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
|
||||||
columns: [_column_60, _column_25, _column_61],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_asset_cloud_id_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
|
||||||
columns: [
|
|
||||||
_column_36,
|
|
||||||
_column_99,
|
|
||||||
_column_100,
|
|
||||||
_column_96,
|
|
||||||
_column_46,
|
|
||||||
_column_47,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape11 memoryEntity = Shape11(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'memory_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_18,
|
|
||||||
_column_15,
|
|
||||||
_column_8,
|
|
||||||
_column_62,
|
|
||||||
_column_63,
|
|
||||||
_column_64,
|
|
||||||
_column_65,
|
|
||||||
_column_66,
|
|
||||||
_column_67,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape12 memoryAssetEntity = Shape12(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'memory_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
|
||||||
columns: [_column_36, _column_68],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape14 personEntity = Shape14(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'person_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_15,
|
|
||||||
_column_1,
|
|
||||||
_column_69,
|
|
||||||
_column_71,
|
|
||||||
_column_72,
|
|
||||||
_column_73,
|
|
||||||
_column_74,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape29 assetFaceEntity = Shape29(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'asset_face_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_0,
|
|
||||||
_column_36,
|
|
||||||
_column_76,
|
|
||||||
_column_77,
|
|
||||||
_column_78,
|
|
||||||
_column_79,
|
|
||||||
_column_80,
|
|
||||||
_column_81,
|
|
||||||
_column_82,
|
|
||||||
_column_83,
|
|
||||||
_column_102,
|
|
||||||
_column_18,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape18 storeEntity = Shape18(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'store_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [_column_87, _column_88, _column_89],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape31 trashedLocalAssetEntity = Shape31(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'trashed_local_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
|
||||||
columns: [
|
|
||||||
_column_1,
|
|
||||||
_column_8,
|
|
||||||
_column_9,
|
|
||||||
_column_5,
|
|
||||||
_column_10,
|
|
||||||
_column_11,
|
|
||||||
_column_12,
|
|
||||||
_column_0,
|
|
||||||
_column_95,
|
|
||||||
_column_22,
|
|
||||||
_column_14,
|
|
||||||
_column_23,
|
|
||||||
_column_97,
|
|
||||||
_column_103,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape32 assetEditEntity = Shape32(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'asset_edit_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [_column_0, _column_36, _column_104, _column_105, _column_106],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
|
||||||
'idx_partner_shared_with_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLatLng = i1.Index(
|
|
||||||
'idx_lat_lng',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
|
||||||
'idx_remote_album_asset_album_asset',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
|
||||||
'idx_remote_asset_cloud_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxPersonOwnerId = i1.Index(
|
|
||||||
'idx_person_owner_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
|
||||||
'idx_asset_face_person_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
|
||||||
'idx_asset_face_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
|
||||||
'idx_trashed_local_asset_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
|
||||||
'idx_trashed_local_asset_album',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
|
||||||
'idx_asset_edit_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class Shape32 extends i0.VersionedTable {
|
|
||||||
Shape32({required super.source, required super.alias}) : super.aliased();
|
|
||||||
i1.GeneratedColumn<String> get id =>
|
|
||||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
|
||||||
i1.GeneratedColumn<String> get assetId =>
|
|
||||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
|
||||||
i1.GeneratedColumn<int> get action =>
|
|
||||||
columnsByName['action']! as i1.GeneratedColumn<int>;
|
|
||||||
i1.GeneratedColumn<i2.Uint8List> get parameters =>
|
|
||||||
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
|
|
||||||
i1.GeneratedColumn<int> get sequence =>
|
|
||||||
columnsByName['sequence']! as i1.GeneratedColumn<int>;
|
|
||||||
}
|
|
||||||
|
|
||||||
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
|
|
||||||
i1.GeneratedColumn<int>(
|
|
||||||
'action',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i1.DriftSqlType.int,
|
|
||||||
);
|
|
||||||
i1.GeneratedColumn<i2.Uint8List> _column_105(String aliasedName) =>
|
|
||||||
i1.GeneratedColumn<i2.Uint8List>(
|
|
||||||
'parameters',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i1.DriftSqlType.blob,
|
|
||||||
);
|
|
||||||
i1.GeneratedColumn<int> _column_106(String aliasedName) =>
|
|
||||||
i1.GeneratedColumn<int>(
|
|
||||||
'sequence',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i1.DriftSqlType.int,
|
|
||||||
);
|
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -10069,7 +9510,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -10173,11 +9613,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from20To21(migrator, schema);
|
await from20To21(migrator, schema);
|
||||||
return 21;
|
return 21;
|
||||||
case 21:
|
|
||||||
final schema = Schema22(database: database);
|
|
||||||
final migrator = i1.Migrator(database, schema);
|
|
||||||
await from21To22(migrator, schema);
|
|
||||||
return 22;
|
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -10205,7 +9640,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -10228,6 +9662,5 @@ i1.OnUpgrade stepByStep({
|
|||||||
from18To19: from18To19,
|
from18To19: from18To19,
|
||||||
from19To20: from19To20,
|
from19To20: from19To20,
|
||||||
from20To21: from20To21,
|
from20To21: from20To21,
|
||||||
from21To22: from21To22,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/db.interface.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 'logger_db.repository.drift.dart';
|
||||||
|
|
||||||
@DriftDatabase(tables: [LogMessageEntity])
|
@DriftDatabase(tables: [LogMessageEntity])
|
||||||
class DriftLogger extends $DriftLogger implements IDatabaseRepository {
|
class DriftLogger extends $DriftLogger implements IDatabaseRepository {
|
||||||
|
|||||||
@@ -1,55 +1,67 @@
|
|||||||
import 'dart:ffi';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cronet_http/cronet_http.dart';
|
||||||
import 'package:cupertino_http/cupertino_http.dart';
|
import 'package:cupertino_http/cupertino_http.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/utils/user_agent.dart';
|
||||||
import 'package:ok_http/ok_http.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:web_socket/web_socket.dart';
|
|
||||||
|
|
||||||
class NetworkRepository {
|
class NetworkRepository {
|
||||||
static http.Client? _client;
|
static late Directory _cachePath;
|
||||||
static Pointer<Void>? _clientPointer;
|
static late String _userAgent;
|
||||||
|
static final _clients = <String, http.Client>{};
|
||||||
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() {
|
||||||
final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
|
return (
|
||||||
if (clientPointer == _clientPointer) {
|
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
|
||||||
return;
|
getUserAgentString().then((userAgent) => _userAgent = userAgent),
|
||||||
}
|
).wait;
|
||||||
_clientPointer = clientPointer;
|
|
||||||
_client?.close();
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
final session = URLSession.fromRawPointer(clientPointer.cast());
|
|
||||||
_client = CupertinoClient.fromSharedSession(session);
|
|
||||||
} else {
|
|
||||||
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
|
static void reset() {
|
||||||
await networkApi.setRequestHeaders(headers, serverUrls, token);
|
Future.microtask(init);
|
||||||
if (Platform.isIOS) {
|
for (final client in _clients.values) {
|
||||||
await init();
|
client.close();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore: avoid-unused-parameters
|
|
||||||
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? protocols}) {
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
final session = URLSession.fromRawPointer(_clientPointer!.cast());
|
|
||||||
return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols);
|
|
||||||
} else {
|
|
||||||
return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols);
|
|
||||||
}
|
}
|
||||||
|
_clients.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkRepository();
|
const NetworkRepository();
|
||||||
|
|
||||||
/// Returns a shared HTTP client that uses native SSL configuration.
|
/// Note: when disk caching is enabled, only one client may use a given directory at a time.
|
||||||
///
|
/// Different isolates or engines must use different directories.
|
||||||
/// On iOS: Uses SharedURLSessionManager's URLSession.
|
http.Client getHttpClient(
|
||||||
/// On Android: Uses SharedHttpClientManager's OkHttpClient.
|
String directoryName, {
|
||||||
///
|
CacheMode cacheMode = CacheMode.memory,
|
||||||
/// Must call [init] before using this method.
|
int diskCapacity = 0,
|
||||||
static http.Client get client => _client!;
|
int maxConnections = 6,
|
||||||
|
int memoryCapacity = 10 << 20,
|
||||||
|
}) {
|
||||||
|
final cachedClient = _clients[directoryName];
|
||||||
|
if (cachedClient != null) {
|
||||||
|
return cachedClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
final directory = Directory('${_cachePath.path}/$directoryName');
|
||||||
|
directory.createSync(recursive: true);
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final engine = CronetEngine.build(
|
||||||
|
cacheMode: cacheMode,
|
||||||
|
cacheMaxSize: diskCapacity,
|
||||||
|
storagePath: directory.path,
|
||||||
|
userAgent: _userAgent,
|
||||||
|
);
|
||||||
|
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final config = URLSessionConfiguration.defaultSessionConfiguration()
|
||||||
|
..httpMaximumConnectionsPerHost = maxConnections
|
||||||
|
..cache = URLCache.withCapacity(
|
||||||
|
diskCapacity: diskCapacity,
|
||||||
|
memoryCapacity: memoryCapacity,
|
||||||
|
directory: directory.uri,
|
||||||
|
)
|
||||||
|
..httpAdditionalHeaders = {'User-Agent': _userAgent};
|
||||||
|
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -33,11 +32,15 @@ class SyncApiRepository {
|
|||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
}) async {
|
}) async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final client = httpClient ?? NetworkRepository.client;
|
final client = httpClient ?? http.Client();
|
||||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||||
|
|
||||||
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
||||||
|
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
await _api.applyToParams([], headerParams);
|
||||||
|
headers.addAll(headerParams);
|
||||||
|
|
||||||
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
|
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
|
||||||
final request = http.Request('POST', Uri.parse(endpoint));
|
final request = http.Request('POST', Uri.parse(endpoint));
|
||||||
request.headers.addAll(headers);
|
request.headers.addAll(headers);
|
||||||
@@ -48,7 +51,6 @@ class SyncApiRepository {
|
|||||||
SyncRequestType.usersV1,
|
SyncRequestType.usersV1,
|
||||||
SyncRequestType.assetsV1,
|
SyncRequestType.assetsV1,
|
||||||
SyncRequestType.assetExifsV1,
|
SyncRequestType.assetExifsV1,
|
||||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
|
|
||||||
SyncRequestType.assetMetadataV1,
|
SyncRequestType.assetMetadataV1,
|
||||||
SyncRequestType.partnersV1,
|
SyncRequestType.partnersV1,
|
||||||
SyncRequestType.partnerAssetsV1,
|
SyncRequestType.partnerAssetsV1,
|
||||||
@@ -117,6 +119,8 @@ class SyncApiRepository {
|
|||||||
}
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
return Future.error(error, stack);
|
return Future.error(error, stack);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
}
|
}
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
||||||
@@ -152,8 +156,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
|||||||
SyncEntityType.assetV1: SyncAssetV1.fromJson,
|
SyncEntityType.assetV1: SyncAssetV1.fromJson,
|
||||||
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
|
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
|
||||||
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
|
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
|
||||||
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
|
|
||||||
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
|
|
||||||
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
|
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
|
||||||
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
|
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
|
||||||
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
|
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.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/models/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
@@ -28,8 +26,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
|
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
|
||||||
import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
|
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
|
||||||
|
|
||||||
class SyncStreamRepository extends DriftDatabaseRepository {
|
class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
final Logger _logger = Logger('DriftSyncStreamRepository');
|
final Logger _logger = Logger('DriftSyncStreamRepository');
|
||||||
@@ -60,7 +58,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
await _db.userEntity.deleteAll();
|
await _db.userEntity.deleteAll();
|
||||||
await _db.userMetadataEntity.deleteAll();
|
await _db.userMetadataEntity.deleteAll();
|
||||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||||
await _db.assetEditEntity.deleteAll();
|
|
||||||
});
|
});
|
||||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||||
});
|
});
|
||||||
@@ -325,63 +322,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
|
|
||||||
try {
|
|
||||||
await _db.batch((batch) {
|
|
||||||
for (final edit in data) {
|
|
||||||
final companion = AssetEditEntityCompanion(
|
|
||||||
id: Value(edit.id),
|
|
||||||
assetId: Value(edit.assetId),
|
|
||||||
action: Value(edit.action.toAssetEditAction()),
|
|
||||||
parameters: Value(edit.parameters as Map<String, Object?>),
|
|
||||||
sequence: Value(edit.sequence),
|
|
||||||
);
|
|
||||||
|
|
||||||
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> replaceAssetEditsV1(String assetId, Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
|
|
||||||
try {
|
|
||||||
await _db.batch((batch) {
|
|
||||||
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
|
|
||||||
|
|
||||||
for (final edit in data) {
|
|
||||||
final companion = AssetEditEntityCompanion(
|
|
||||||
id: Value(edit.id),
|
|
||||||
assetId: Value(edit.assetId),
|
|
||||||
action: Value(edit.action.toAssetEditAction()),
|
|
||||||
parameters: Value(edit.parameters as Map<String, Object?>),
|
|
||||||
sequence: Value(edit.sequence),
|
|
||||||
);
|
|
||||||
|
|
||||||
batch.insert(_db.assetEditEntity, companion);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> data, {String debugLabel = 'user'}) async {
|
|
||||||
try {
|
|
||||||
await _db.batch((batch) {
|
|
||||||
for (final edit in data) {
|
|
||||||
batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
@@ -858,12 +798,3 @@ extension on String {
|
|||||||
extension on UserAvatarColor {
|
extension on UserAvatarColor {
|
||||||
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
|
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on api.AssetEditAction {
|
|
||||||
AssetEditAction toAssetEditAction() => switch (this) {
|
|
||||||
api.AssetEditAction.crop => AssetEditAction.crop,
|
|
||||||
api.AssetEditAction.rotate => AssetEditAction.rotate,
|
|
||||||
api.AssetEditAction.mirror => AssetEditAction.mirror,
|
|
||||||
_ => AssetEditAction.other,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -276,19 +276,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
origin: origin,
|
origin: origin,
|
||||||
);
|
);
|
||||||
|
|
||||||
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
|
|
||||||
(
|
|
||||||
bucketSource: () async* {
|
|
||||||
yield _generateBuckets(getAssets().length);
|
|
||||||
yield* assetCount.map(_generateBuckets);
|
|
||||||
},
|
|
||||||
assetSource: (offset, count) {
|
|
||||||
final assets = getAssets();
|
|
||||||
return Future.value(assets.skip(offset).take(count).toList(growable: false));
|
|
||||||
},
|
|
||||||
origin: origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
|
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||||
// Sort assets by date descending and group by day
|
// Sort assets by date descending and group by day
|
||||||
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
|
|||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/utils/licenses.dart';
|
import 'package:immich_mobile/utils/licenses.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
@@ -59,6 +60,7 @@ void main() async {
|
|||||||
// Warm-up isolate pool for worker manager
|
// Warm-up isolate pool for worker manager
|
||||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||||
await migrateDatabaseIfNeeded(isar, drift);
|
await migrateDatabaseIfNeeded(isar, drift);
|
||||||
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
@@ -244,7 +246,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
@override
|
@override
|
||||||
void reassemble() {
|
void reassemble() {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
NetworkRepository.init();
|
NetworkRepository.reset();
|
||||||
}
|
}
|
||||||
super.reassemble();
|
super.reassemble();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||||
|
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class BackUpState {
|
|||||||
final DateTime progressInFileSpeedUpdateTime;
|
final DateTime progressInFileSpeedUpdateTime;
|
||||||
final int progressInFileSpeedUpdateSentBytes;
|
final int progressInFileSpeedUpdateSentBytes;
|
||||||
final double iCloudDownloadProgress;
|
final double iCloudDownloadProgress;
|
||||||
|
final CancellationToken cancelToken;
|
||||||
final ServerDiskInfo serverInfo;
|
final ServerDiskInfo serverInfo;
|
||||||
final bool autoBackup;
|
final bool autoBackup;
|
||||||
final bool backgroundBackup;
|
final bool backgroundBackup;
|
||||||
@@ -51,6 +53,7 @@ class BackUpState {
|
|||||||
required this.progressInFileSpeedUpdateTime,
|
required this.progressInFileSpeedUpdateTime,
|
||||||
required this.progressInFileSpeedUpdateSentBytes,
|
required this.progressInFileSpeedUpdateSentBytes,
|
||||||
required this.iCloudDownloadProgress,
|
required this.iCloudDownloadProgress,
|
||||||
|
required this.cancelToken,
|
||||||
required this.serverInfo,
|
required this.serverInfo,
|
||||||
required this.autoBackup,
|
required this.autoBackup,
|
||||||
required this.backgroundBackup,
|
required this.backgroundBackup,
|
||||||
@@ -75,6 +78,7 @@ class BackUpState {
|
|||||||
DateTime? progressInFileSpeedUpdateTime,
|
DateTime? progressInFileSpeedUpdateTime,
|
||||||
int? progressInFileSpeedUpdateSentBytes,
|
int? progressInFileSpeedUpdateSentBytes,
|
||||||
double? iCloudDownloadProgress,
|
double? iCloudDownloadProgress,
|
||||||
|
CancellationToken? cancelToken,
|
||||||
ServerDiskInfo? serverInfo,
|
ServerDiskInfo? serverInfo,
|
||||||
bool? autoBackup,
|
bool? autoBackup,
|
||||||
bool? backgroundBackup,
|
bool? backgroundBackup,
|
||||||
@@ -98,6 +102,7 @@ class BackUpState {
|
|||||||
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
||||||
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
||||||
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||||
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
serverInfo: serverInfo ?? this.serverInfo,
|
serverInfo: serverInfo ?? this.serverInfo,
|
||||||
autoBackup: autoBackup ?? this.autoBackup,
|
autoBackup: autoBackup ?? this.autoBackup,
|
||||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||||
@@ -115,7 +120,7 @@ class BackUpState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -132,6 +137,7 @@ class BackUpState {
|
|||||||
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
||||||
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
||||||
other.iCloudDownloadProgress == iCloudDownloadProgress &&
|
other.iCloudDownloadProgress == iCloudDownloadProgress &&
|
||||||
|
other.cancelToken == cancelToken &&
|
||||||
other.serverInfo == serverInfo &&
|
other.serverInfo == serverInfo &&
|
||||||
other.autoBackup == autoBackup &&
|
other.autoBackup == autoBackup &&
|
||||||
other.backgroundBackup == backgroundBackup &&
|
other.backgroundBackup == backgroundBackup &&
|
||||||
@@ -157,6 +163,7 @@ class BackUpState {
|
|||||||
progressInFileSpeedUpdateTime.hashCode ^
|
progressInFileSpeedUpdateTime.hashCode ^
|
||||||
progressInFileSpeedUpdateSentBytes.hashCode ^
|
progressInFileSpeedUpdateSentBytes.hashCode ^
|
||||||
iCloudDownloadProgress.hashCode ^
|
iCloudDownloadProgress.hashCode ^
|
||||||
|
cancelToken.hashCode ^
|
||||||
serverInfo.hashCode ^
|
serverInfo.hashCode ^
|
||||||
autoBackup.hashCode ^
|
autoBackup.hashCode ^
|
||||||
backgroundBackup.hashCode ^
|
backgroundBackup.hashCode ^
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
|
||||||
class ManualUploadState {
|
class ManualUploadState {
|
||||||
|
final CancellationToken cancelToken;
|
||||||
|
|
||||||
// Current Backup Asset
|
// Current Backup Asset
|
||||||
final CurrentUploadAsset currentUploadAsset;
|
final CurrentUploadAsset currentUploadAsset;
|
||||||
final int currentAssetIndex;
|
final int currentAssetIndex;
|
||||||
@@ -26,6 +29,7 @@ class ManualUploadState {
|
|||||||
required this.progressInFileSpeeds,
|
required this.progressInFileSpeeds,
|
||||||
required this.progressInFileSpeedUpdateTime,
|
required this.progressInFileSpeedUpdateTime,
|
||||||
required this.progressInFileSpeedUpdateSentBytes,
|
required this.progressInFileSpeedUpdateSentBytes,
|
||||||
|
required this.cancelToken,
|
||||||
required this.currentUploadAsset,
|
required this.currentUploadAsset,
|
||||||
required this.totalAssetsToUpload,
|
required this.totalAssetsToUpload,
|
||||||
required this.currentAssetIndex,
|
required this.currentAssetIndex,
|
||||||
@@ -40,6 +44,7 @@ class ManualUploadState {
|
|||||||
List<double>? progressInFileSpeeds,
|
List<double>? progressInFileSpeeds,
|
||||||
DateTime? progressInFileSpeedUpdateTime,
|
DateTime? progressInFileSpeedUpdateTime,
|
||||||
int? progressInFileSpeedUpdateSentBytes,
|
int? progressInFileSpeedUpdateSentBytes,
|
||||||
|
CancellationToken? cancelToken,
|
||||||
CurrentUploadAsset? currentUploadAsset,
|
CurrentUploadAsset? currentUploadAsset,
|
||||||
int? totalAssetsToUpload,
|
int? totalAssetsToUpload,
|
||||||
int? successfulUploads,
|
int? successfulUploads,
|
||||||
@@ -53,6 +58,7 @@ class ManualUploadState {
|
|||||||
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
|
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
|
||||||
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
||||||
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
||||||
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
||||||
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
|
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
|
||||||
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
|
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
|
||||||
@@ -63,7 +69,7 @@ class ManualUploadState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
|
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,6 +84,7 @@ class ManualUploadState {
|
|||||||
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
|
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
|
||||||
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
||||||
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
||||||
|
other.cancelToken == cancelToken &&
|
||||||
other.currentUploadAsset == currentUploadAsset &&
|
other.currentUploadAsset == currentUploadAsset &&
|
||||||
other.totalAssetsToUpload == totalAssetsToUpload &&
|
other.totalAssetsToUpload == totalAssetsToUpload &&
|
||||||
other.currentAssetIndex == currentAssetIndex &&
|
other.currentAssetIndex == currentAssetIndex &&
|
||||||
@@ -93,6 +100,7 @@ class ManualUploadState {
|
|||||||
progressInFileSpeeds.hashCode ^
|
progressInFileSpeeds.hashCode ^
|
||||||
progressInFileSpeedUpdateTime.hashCode ^
|
progressInFileSpeedUpdateTime.hashCode ^
|
||||||
progressInFileSpeedUpdateSentBytes.hashCode ^
|
progressInFileSpeedUpdateSentBytes.hashCode ^
|
||||||
|
cancelToken.hashCode ^
|
||||||
currentUploadAsset.hashCode ^
|
currentUploadAsset.hashCode ^
|
||||||
totalAssetsToUpload.hashCode ^
|
totalAssetsToUpload.hashCode ^
|
||||||
currentAssetIndex.hashCode ^
|
currentAssetIndex.hashCode ^
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
await backupNotifier.startForegroundBackup(currentUser.id);
|
await backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> stopBackup() async {
|
||||||
|
await backupNotifier.stopForegroundBackup();
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -132,9 +136,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
BackupToggleButton(
|
BackupToggleButton(
|
||||||
onStart: () async => await startBackup(),
|
onStart: () async => await startBackup(),
|
||||||
onStop: () {
|
onStop: () async {
|
||||||
syncSuccess = null;
|
syncSuccess = null;
|
||||||
backupNotifier.stopForegroundBackup();
|
await stopBackup();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
switch (error) {
|
switch (error) {
|
||||||
@@ -148,13 +152,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
|
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Text(
|
||||||
child: Text(
|
|
||||||
context.t.backup_error_sync_failed,
|
context.t.backup_error_sync_failed,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -346,7 +348,6 @@ class _RemainderCard extends ConsumerWidget {
|
|||||||
remainderCount.toString(),
|
remainderCount.toString(),
|
||||||
style: context.textTheme.titleLarge?.copyWith(
|
style: context.textTheme.titleLarge?.copyWith(
|
||||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||||
fontFeatures: [const FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (syncStatus.isRemoteSyncing)
|
if (syncStatus.isRemoteSyncing)
|
||||||
@@ -486,7 +487,6 @@ class _PreparingStatusState extends ConsumerState {
|
|||||||
style: context.textTheme.titleMedium?.copyWith(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
color: context.colorScheme.primary,
|
color: context.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFeatures: [const FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -511,7 +511,6 @@ class _PreparingStatusState extends ConsumerState {
|
|||||||
style: context.textTheme.titleMedium?.copyWith(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFeatures: [const FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -112,15 +112,16 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
// Waits for hashing to be cancelled before starting a new one
|
// Waits for hashing to be cancelled before starting a new one
|
||||||
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||||
if (isBackupEnabled) {
|
if (isBackupEnabled) {
|
||||||
backupNotifier.stopForegroundBackup();
|
|
||||||
unawaited(
|
unawaited(
|
||||||
backgroundSync.syncRemote().then((success) {
|
backupNotifier.stopForegroundBackup().whenComplete(
|
||||||
|
() => backgroundSync.syncRemote().then((success) {
|
||||||
if (success) {
|
if (success) {
|
||||||
return backupNotifier.startForegroundBackup(user.id);
|
return backupNotifier.startForegroundBackup(user.id);
|
||||||
} else {
|
} else {
|
||||||
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,15 +59,16 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||||
backupNotifier.stopForegroundBackup();
|
|
||||||
unawaited(
|
unawaited(
|
||||||
backgroundSync.syncRemote().then((success) {
|
backupNotifier.stopForegroundBackup().whenComplete(
|
||||||
|
() => backgroundSync.syncRemote().then((success) {
|
||||||
if (success) {
|
if (success) {
|
||||||
return backupNotifier.startForegroundBackup(currentUser.id);
|
return backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
} else {
|
} else {
|
||||||
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user