Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455b68e562 | ||
|
|
aca5562f81 | ||
|
|
7c15f51355 | ||
|
|
a5f71b576b | ||
|
|
75c79a8344 | ||
|
|
73f8599c7f | ||
|
|
e494b17117 | ||
|
|
789b58398a | ||
|
|
8e5a53ade8 | ||
|
|
6e43d52680 |
@@ -0,0 +1,44 @@
|
|||||||
|
Categories:
|
||||||
|
- Theming
|
||||||
|
License: GPL-3.0-only
|
||||||
|
AuthorName: Christoph
|
||||||
|
WebSite: https://git.cbsk-tech.de/Christoph/LockScreenWallpaper
|
||||||
|
SourceCode: https://git.cbsk-tech.de/Christoph/LockScreenWallpaper
|
||||||
|
IssueTracker: https://git.cbsk-tech.de/Christoph/LockScreenWallpaper/issues
|
||||||
|
|
||||||
|
AutoName: WallpaperFlow
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://git.cbsk-tech.de/Christoph/LockScreenWallpaper.git
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 0.1.0
|
||||||
|
versionCode: 1000
|
||||||
|
commit: v0.1.0
|
||||||
|
ndk: 28.2.13676358
|
||||||
|
sudo:
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y npm make gcc libc-dev
|
||||||
|
srclibs:
|
||||||
|
- rustup@1.28.2
|
||||||
|
- esbuild@v0.25.12
|
||||||
|
scandelete:
|
||||||
|
- node_modules
|
||||||
|
prebuild:
|
||||||
|
- npm ci --ignore-scripts
|
||||||
|
- $$rustup$$/rustup-init.sh -y --default-toolchain 1.95.0 --target aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||||
|
- source $$HOME$$/.cargo/env
|
||||||
|
- cargo install tauri-cli --version 2.11.4 --locked
|
||||||
|
- pushd $$esbuild$$ && make esbuild && popd
|
||||||
|
- mv $$esbuild$$/esbuild node_modules/@esbuild/linux-x64/bin/esbuild
|
||||||
|
build:
|
||||||
|
- source $$HOME$$/.cargo/env
|
||||||
|
- export NDK_HOME=$$NDK$$
|
||||||
|
- export ANDROID_NDK_HOME=$$NDK$$
|
||||||
|
- cargo tauri android build --apk
|
||||||
|
output: src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
|
||||||
|
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
CurrentVersion: 0.1.0
|
||||||
|
CurrentVersionCode: 1000
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release-apk:
|
||||||
|
name: Build signed release APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
env:
|
||||||
|
ANDROID_COMPILE_SDK: "36"
|
||||||
|
ANDROID_BUILD_TOOLS: "36.0.0"
|
||||||
|
ANDROID_NDK_VERSION: "30.0.15729638"
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@1.95.0
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri -> target
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager \
|
||||||
|
"platforms;android-${ANDROID_COMPILE_SDK}" \
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS}" \
|
||||||
|
"ndk;${ANDROID_NDK_VERSION}"
|
||||||
|
echo "NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "${GITEA_ENV}"
|
||||||
|
echo "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}" >> "${GITEA_PATH}"
|
||||||
|
|
||||||
|
- name: Install JavaScript dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Apply version from release tag
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
|
||||||
|
echo "Release builds must run on a tag such as v0.1.0." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} must use the format vMAJOR.MINOR.PATCH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "${RELEASE_VERSION}"
|
||||||
|
if (( 10#${VERSION_MINOR} > 999 || 10#${VERSION_PATCH} > 999 )); then
|
||||||
|
echo "Minor and patch versions must not exceed 999." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ANDROID_VERSION_CODE=$((10#${VERSION_MAJOR} * 1000000 + 10#${VERSION_MINOR} * 1000 + 10#${VERSION_PATCH}))
|
||||||
|
if (( ANDROID_VERSION_CODE < 1 || ANDROID_VERSION_CODE > 2100000000 )); then
|
||||||
|
echo "Derived Android versionCode ${ANDROID_VERSION_CODE} is outside the allowed range." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_VERSION="${RELEASE_VERSION}" \
|
||||||
|
ANDROID_VERSION_CODE="${ANDROID_VERSION_CODE}" \
|
||||||
|
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const version = process.env.RELEASE_VERSION;
|
||||||
|
const versionCode = process.env.ANDROID_VERSION_CODE;
|
||||||
|
|
||||||
|
function updateJson(path, update) {
|
||||||
|
const value = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
update(value);
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateJson("package.json", value => { value.version = version; });
|
||||||
|
updateJson("package-lock.json", value => {
|
||||||
|
value.version = version;
|
||||||
|
value.packages[""].version = version;
|
||||||
|
});
|
||||||
|
updateJson("src-tauri/tauri.conf.json", value => { value.version = version; });
|
||||||
|
|
||||||
|
const cargoTomlPath = "src-tauri/Cargo.toml";
|
||||||
|
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8").replace(
|
||||||
|
/(\[package\][\s\S]*?\nversion = ")[^"]+("\n)/,
|
||||||
|
`$1${version}$2`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(cargoTomlPath, cargoToml);
|
||||||
|
|
||||||
|
const cargoLockPath = "src-tauri/Cargo.lock";
|
||||||
|
const cargoLock = fs.readFileSync(cargoLockPath, "utf8").replace(
|
||||||
|
/(\[\[package\]\]\nname = "lockscreenwallpaper"\nversion = ")[^"]+("\n)/,
|
||||||
|
`$1${version}$2`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(cargoLockPath, cargoLock);
|
||||||
|
|
||||||
|
const fdroidPath = ".fdroid.yml";
|
||||||
|
let fdroid = fs.readFileSync(fdroidPath, "utf8");
|
||||||
|
fdroid = fdroid
|
||||||
|
.replace(/^ - versionName: .*$/m, ` - versionName: ${version}`)
|
||||||
|
.replace(/^ versionCode: .*$/m, ` versionCode: ${versionCode}`)
|
||||||
|
.replace(/^ commit: .*$/m, ` commit: ${process.env.GITHUB_REF_NAME}`)
|
||||||
|
.replace(/^CurrentVersion: .*$/m, `CurrentVersion: ${version}`)
|
||||||
|
.replace(/^CurrentVersionCode: .*$/m, `CurrentVersionCode: ${versionCode}`);
|
||||||
|
fs.writeFileSync(fdroidPath, fdroid);
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITEA_ENV}"
|
||||||
|
echo "ANDROID_VERSION_CODE=${ANDROID_VERSION_CODE}" >> "${GITEA_ENV}"
|
||||||
|
echo "Building ${GITHUB_REF_NAME} with Android versionCode ${ANDROID_VERSION_CODE}."
|
||||||
|
|
||||||
|
- name: Validate signing secrets
|
||||||
|
run: |
|
||||||
|
for SECRET_NAME in \
|
||||||
|
ANDROID_KEYSTORE_BASE64 \
|
||||||
|
ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
ANDROID_KEY_ALIAS \
|
||||||
|
ANDROID_KEY_PASSWORD \
|
||||||
|
GITEA_RELEASE_TOKEN
|
||||||
|
do
|
||||||
|
if [ -z "${!SECRET_NAME:-}" ]; then
|
||||||
|
echo "Missing Gitea Actions secret: ${SECRET_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Restore release keystore
|
||||||
|
run: |
|
||||||
|
SIGNING_DIRECTORY="${RUNNER_TEMP}/wallpaperflow-signing"
|
||||||
|
mkdir -p "${SIGNING_DIRECTORY}"
|
||||||
|
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${SIGNING_DIRECTORY}/release.jks"
|
||||||
|
chmod 600 "${SIGNING_DIRECTORY}/release.jks"
|
||||||
|
echo "ANDROID_SIGNING_KEYSTORE=${SIGNING_DIRECTORY}/release.jks" >> "${GITEA_ENV}"
|
||||||
|
|
||||||
|
- name: Build unsigned release APK
|
||||||
|
run: npm run tauri -- android build --apk --ci
|
||||||
|
|
||||||
|
- name: Verify generated Android version
|
||||||
|
run: |
|
||||||
|
TAURI_PROPERTIES="src-tauri/gen/android/app/tauri.properties"
|
||||||
|
grep -Fx "tauri.android.versionName=${RELEASE_VERSION}" "${TAURI_PROPERTIES}"
|
||||||
|
grep -Fx "tauri.android.versionCode=${ANDROID_VERSION_CODE}" "${TAURI_PROPERTIES}"
|
||||||
|
|
||||||
|
- name: Align and sign APK
|
||||||
|
run: |
|
||||||
|
UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
|
||||||
|
RELEASE_DIRECTORY="release"
|
||||||
|
ALIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release-aligned.apk"
|
||||||
|
SIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release.apk"
|
||||||
|
|
||||||
|
test -f "${UNSIGNED_APK}"
|
||||||
|
mkdir -p "${RELEASE_DIRECTORY}"
|
||||||
|
zipalign -p -f 4 "${UNSIGNED_APK}" "${ALIGNED_APK}"
|
||||||
|
apksigner sign \
|
||||||
|
--ks "${ANDROID_SIGNING_KEYSTORE}" \
|
||||||
|
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
|
||||||
|
--ks-pass env:ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
--key-pass env:ANDROID_KEY_PASSWORD \
|
||||||
|
--out "${SIGNED_APK}" \
|
||||||
|
"${ALIGNED_APK}"
|
||||||
|
apksigner verify --verbose --print-certs "${SIGNED_APK}"
|
||||||
|
rm "${ALIGNED_APK}"
|
||||||
|
(cd "${RELEASE_DIRECTORY}" && sha256sum WallpaperFlow-release.apk > WallpaperFlow-release.apk.sha256)
|
||||||
|
|
||||||
|
- name: Attach APK to Gitea release
|
||||||
|
run: |
|
||||||
|
API_ROOT="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
RELEASE_JSON="${RUNNER_TEMP}/wallpaperflow-release.json"
|
||||||
|
CREATE_JSON="${RUNNER_TEMP}/wallpaperflow-create-release.json"
|
||||||
|
ASSETS_JSON="${RUNNER_TEMP}/wallpaperflow-release-assets.json"
|
||||||
|
|
||||||
|
STATUS="$(curl --silent --show-error \
|
||||||
|
--output "${RELEASE_JSON}" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
|
||||||
|
"${API_ROOT}/releases/tags/${GITHUB_REF_NAME}")"
|
||||||
|
|
||||||
|
if [ "${STATUS}" = "404" ]; then
|
||||||
|
node -e '
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
tag_name: process.env.GITHUB_REF_NAME,
|
||||||
|
name: `WallpaperFlow ${process.env.GITHUB_REF_NAME}`,
|
||||||
|
body: "Automatisch erstelltes Android-Release.",
|
||||||
|
draft: false,
|
||||||
|
prerelease: false
|
||||||
|
}))
|
||||||
|
' > "${CREATE_JSON}"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-binary "@${CREATE_JSON}" \
|
||||||
|
--output "${RELEASE_JSON}" \
|
||||||
|
"${API_ROOT}/releases"
|
||||||
|
elif [ "${STATUS}" != "200" ]; then
|
||||||
|
echo "Could not read Gitea release for ${GITHUB_REF_NAME} (HTTP ${STATUS})." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID="$(node -e '
|
||||||
|
const release = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||||
|
if (!release.id) process.exit(1);
|
||||||
|
process.stdout.write(String(release.id));
|
||||||
|
' "${RELEASE_JSON}")"
|
||||||
|
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
|
||||||
|
--output "${ASSETS_JSON}" \
|
||||||
|
"${API_ROOT}/releases/${RELEASE_ID}/assets"
|
||||||
|
|
||||||
|
for FILE in \
|
||||||
|
release/WallpaperFlow-release.apk \
|
||||||
|
release/WallpaperFlow-release.apk.sha256
|
||||||
|
do
|
||||||
|
ASSET_NAME="$(basename "${FILE}")"
|
||||||
|
ASSET_ID="$(ASSET_NAME="${ASSET_NAME}" node -e '
|
||||||
|
const assets = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||||
|
const asset = assets.find(item => item.name === process.env.ASSET_NAME);
|
||||||
|
if (asset) process.stdout.write(String(asset.id));
|
||||||
|
' "${ASSETS_JSON}")"
|
||||||
|
|
||||||
|
if [ -n "${ASSET_ID}" ]; then
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request DELETE \
|
||||||
|
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
|
||||||
|
"${API_ROOT}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
|
||||||
|
--form "attachment=@${FILE}" \
|
||||||
|
"${API_ROOT}/releases/${RELEASE_ID}/assets" >/dev/null
|
||||||
|
done
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Datenschutzerklärung / Privacy Policy
|
||||||
|
|
||||||
|
Stand / Last updated: 21. August 2026
|
||||||
|
|
||||||
|
## Deutsch
|
||||||
|
|
||||||
|
WallpaperFlow verarbeitet die vom Nutzer ausgewählten Bilder grundsätzlich lokal auf dem Android-Gerät. Die Bilder werden in den privaten App-Speicher kopiert und nur dazu verwendet, Vorschaubilder anzuzeigen und das vom Nutzer gewählte Hintergrund- oder Sperrbildschirmbild zu setzen.
|
||||||
|
|
||||||
|
Optional kann der Nutzer die App mit einem eigenen, selbst gehosteten Immich-Server verbinden. In diesem Fall sendet WallpaperFlow den API-Key ausschließlich an die eingetragene Server-URL, um die Verbindung zu prüfen, Alben und Vorschaubilder abzurufen und ausdrücklich ausgewählte Bilder herunterzuladen. Der API-Key wird mit einem Schlüssel aus dem Android Keystore verschlüsselt gespeichert. Die Server-URL und der angezeigte Benutzername werden im privaten App-Speicher abgelegt. WallpaperFlow lädt keine Bilder zu Immich hoch und verändert keine Daten auf dem Server. Bei einer unverschlüsselten HTTP-URL weist die App auf die fehlende Transportverschlüsselung hin.
|
||||||
|
|
||||||
|
WallpaperFlow:
|
||||||
|
|
||||||
|
- erhebt keine personenbezogenen Daten,
|
||||||
|
- überträgt keine Bilder oder Einstellungen an fremde Server,
|
||||||
|
- gibt keine Daten an Dritte weiter,
|
||||||
|
- enthält keine Werbung, Analyse-, Tracking- oder Profiling-Dienste und
|
||||||
|
- verlangt keine Registrierung und kein Benutzerkonto.
|
||||||
|
|
||||||
|
Die Einstellungen und importierten Bilder können durch das Löschen einzelner Bilder innerhalb der App oder durch das Löschen der App-Daten beziehungsweise die Deinstallation der App entfernt werden.
|
||||||
|
|
||||||
|
Die App verwendet einen sichtbaren Android-Hintergrunddienst, um den vom Nutzer aktivierten automatischen Bildwechsel auszuführen. Nach einem Geräteneustart kann die Zeitplanung wiederhergestellt werden. Dabei werden keine Daten außerhalb des Geräts verarbeitet.
|
||||||
|
|
||||||
|
Fragen zum Datenschutz können über den Issue-Tracker des Projekts gestellt werden:
|
||||||
|
https://git.cbsk-tech.de/Christoph/LockScreenWallpaper/issues
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
WallpaperFlow generally processes user-selected images on the local Android device. Images are copied into the app's private storage and are used only to display previews and to set the wallpaper or lock-screen image selected by the user.
|
||||||
|
|
||||||
|
Users may optionally connect the app to their own self-hosted Immich server. In this case, WallpaperFlow sends the API key only to the configured server URL to test the connection, retrieve albums and thumbnails, and download explicitly selected images. The API key is encrypted with a key held in the Android Keystore. The server URL and displayed user name are stored in the app's private storage. WallpaperFlow does not upload images to Immich or modify server data. The app warns users when an unencrypted HTTP URL is used.
|
||||||
|
|
||||||
|
WallpaperFlow:
|
||||||
|
|
||||||
|
- does not collect personal data,
|
||||||
|
- does not transmit images or settings to third-party servers,
|
||||||
|
- does not share data with third parties,
|
||||||
|
- contains no advertising, analytics, tracking or profiling services, and
|
||||||
|
- requires no registration or user account.
|
||||||
|
|
||||||
|
Settings and imported images can be removed by deleting individual images in the app, clearing the app's data or uninstalling the app.
|
||||||
|
|
||||||
|
The app uses a visible Android background service to perform automatic wallpaper rotation after the user enables it. Scheduling may be restored after a device restart. No data is processed outside the device for this purpose.
|
||||||
|
|
||||||
|
Privacy questions can be submitted through the project's issue tracker:
|
||||||
|
https://git.cbsk-tech.de/Christoph/LockScreenWallpaper/issues
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# LockScreenWallpaper
|
# WallpaperFlow
|
||||||
|
|
||||||
LockScreenWallpaper ist eine Tauri-2-App für Android, die aus einer frei wählbaren Bildsammlung bei jedem Aktivieren des Displays ein neues Sperrbildschirmbild setzt.
|
WallpaperFlow ist eine freie Tauri-2-App für Android, die aus einer frei wählbaren Bildsammlung in einem einstellbaren Zeitintervall ein neues Sperrbildschirmbild setzt.
|
||||||
|
|
||||||
## Was bereits umgesetzt ist
|
## Was bereits umgesetzt ist
|
||||||
|
|
||||||
@@ -9,11 +9,16 @@ LockScreenWallpaper ist eine Tauri-2-App für Android, die aus einer frei wählb
|
|||||||
- Wechsel nur für den Sperrbildschirm oder für das allgemeine Hintergrundbild
|
- Wechsel nur für den Sperrbildschirm oder für das allgemeine Hintergrundbild
|
||||||
- zufällige oder fortlaufende Reihenfolge
|
- zufällige oder fortlaufende Reihenfolge
|
||||||
- manueller Wechsel über „Nächstes Motiv“
|
- manueller Wechsel über „Nächstes Motiv“
|
||||||
- automatischer Wechsel bei `ACTION_SCREEN_ON`
|
- automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden
|
||||||
- Neustart des Dienstes nach einem Geräte-Neustart, soweit die Android-Version Hintergrundstarts zulässt
|
- optionaler Wechsel bei jedem Einschalten des Displays
|
||||||
|
- erneute Planung des automatischen Wechsels nach einem Geräte-Neustart
|
||||||
- lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht
|
- lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht
|
||||||
|
- optionaler Import aus einem selbst gehosteten Immich-Server über einen eingeschränkten API-Key
|
||||||
|
- vollständig lokale Oberfläche in Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Polnisch, Portugiesisch, Japanisch, Koreanisch und vereinfachtem Chinesisch
|
||||||
|
|
||||||
Android hält den Listener über einen Foreground Service aktiv. Solange der automatische Wechsel eingeschaltet ist, zeigt das System deshalb eine dauerhafte, stille Benachrichtigung. Auf Geräten mit aggressivem Energiesparen muss LockScreenWallpaper gegebenenfalls von der Akku-Optimierung ausgenommen werden.
|
Der automatische Wechsel verwendet einen ungenauen Android-Alarm und respektiert Doze sowie den Energiesparmodus. Dadurch gibt es keinen dauerhaft laufenden Dienst und keine permanente Benachrichtigung; Wechsel können im Ruhezustand etwas später erfolgen. Automatische Immich-Downloads erfolgen nur über eine nicht getaktete Verbindung und bei ausreichendem Akkustand.
|
||||||
|
|
||||||
|
Der optionale Modus „Beim Einschalten des Displays“ benötigt dagegen einen kleinen Foreground Service mit permanenter Benachrichtigung, damit Android die Display-Ereignisse zuverlässig zustellt. Das nächste Bild wird bereits beim Ausschalten gesetzt und ist dadurch beim folgenden Einschalten ohne sichtbare Verzögerung vorhanden. Der Dienst hält keinen CPU-Wake-Lock.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
@@ -42,9 +47,76 @@ npm run tauri android dev
|
|||||||
|
|
||||||
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
|
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
|
||||||
|
|
||||||
|
## Signierte Android-Releases mit Gitea Actions
|
||||||
|
|
||||||
|
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v1.2.3`
|
||||||
|
eine signierte Universal-APK. Der Tag ist dabei die Quelle für alle
|
||||||
|
Versionsnummern im Build. Die Pipeline setzt vorübergehend die Versionen in
|
||||||
|
`package.json`, `package-lock.json`, `src-tauri/tauri.conf.json`,
|
||||||
|
`src-tauri/Cargo.toml`, `src-tauri/Cargo.lock` und `.fdroid.yml`. Diese Änderungen
|
||||||
|
gelten nur im Arbeitsverzeichnis des Runners und werden nicht zurück ins
|
||||||
|
Repository geschrieben.
|
||||||
|
|
||||||
|
Aus `v1.2.3` erzeugt Tauri den Android-`versionCode` `1002003` nach dem Schema
|
||||||
|
`major * 1000000 + minor * 1000 + patch`. Der Workflow kann außerdem manuell
|
||||||
|
über die Actions-Oberfläche gestartet werden, wenn dabei ein Versions-Tag als
|
||||||
|
Ref ausgewählt wird.
|
||||||
|
|
||||||
|
In den Repository-Einstellungen unter **Settings → Actions → Secrets** müssen
|
||||||
|
folgende Secrets angelegt werden:
|
||||||
|
|
||||||
|
- `ANDROID_KEYSTORE_BASE64`: der Base64-kodierte Inhalt des Release-Keystores
|
||||||
|
- `ANDROID_KEYSTORE_PASSWORD`: Passwort des Keystores
|
||||||
|
- `ANDROID_KEY_ALIAS`: Alias des Signaturschlüssels
|
||||||
|
- `ANDROID_KEY_PASSWORD`: Passwort des Signaturschlüssels
|
||||||
|
|
||||||
|
Den Keystore-Inhalt für das Secret erzeugt man unter Linux mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 -w 0 /sicherer/pfad/wallpaperflow-release.jks
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach erfolgreichem Lauf erstellt die Pipeline ein Gitea-Release für den Tag
|
||||||
|
beziehungsweise verwendet ein bereits vorhandenes Release. Die Dateien
|
||||||
|
`WallpaperFlow-release.apk` und `WallpaperFlow-release.apk.sha256` werden direkt
|
||||||
|
an dieses Release angehängt. Bei einem erneuten Lauf für denselben Tag werden
|
||||||
|
vorhandene Anhänge mit diesen Namen ersetzt. Dafür muss der integrierte
|
||||||
|
`GITEA_TOKEN` Schreibzugriff auf Releases besitzen; die Pipeline fordert die
|
||||||
|
Berechtigung `releases: write` selbst an.
|
||||||
|
|
||||||
|
Der Keystore darf nicht in das Repository eingecheckt werden und muss dauerhaft
|
||||||
|
gesichert bleiben, da spätere Updates mit demselben Schlüssel signiert werden
|
||||||
|
müssen.
|
||||||
|
|
||||||
|
Voraussetzung ist ein aktiver Gitea-Actions-Runner mit dem Label
|
||||||
|
`ubuntu-latest`, Netzwerkzugriff auf npm, Rust und die Android-SDK-Server sowie
|
||||||
|
ausreichend Speicher für Android SDK, NDK und Rust-Buildartefakte.
|
||||||
|
|
||||||
## Wichtige Android-Hinweise
|
## Wichtige Android-Hinweise
|
||||||
|
|
||||||
- Android erlaubt keine komplett unsichtbare, dauerhaft laufende Überwachung des Display-Status. Der automatische Wechsel verwendet deshalb korrekt einen Foreground Service.
|
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
|
||||||
- `WallpaperManager.FLAG_LOCK` ist ab Android 7 verfügbar. Auf älteren Geräten setzt die App das allgemeine Hintergrundbild.
|
- `WallpaperManager.FLAG_LOCK` ist ab Android 7 verfügbar. Auf älteren Geräten setzt die App das allgemeine Hintergrundbild.
|
||||||
- Hersteller können Hintergrunddienste zusätzlich einschränken. Besonders bei Samsung/Xiaomi kann eine Ausnahme von der Akku-Optimierung nötig sein.
|
- Hersteller können Hintergrundarbeit zusätzlich verzögern. Eine Ausnahme von der Akku-Optimierung ist für den normalen Betrieb nicht vorgesehen.
|
||||||
- „Ohne Limit“ bedeutet: kein App-Zähler wie Samsungs 15-Bilder-Grenze. Praktisch begrenzen freier Gerätespeicher und Dateisystem die Sammlung.
|
- „Ohne Limit“ bedeutet: kein App-Zähler wie Samsungs 15-Bilder-Grenze. Praktisch begrenzen freier Gerätespeicher und Dateisystem die Sammlung.
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
WallpaperFlow verarbeitet importierte Bilder lokal auf dem Gerät. Die optionale Immich-Anbindung kommuniziert ausschließlich mit dem vom Nutzer eingetragenen, selbst gehosteten Server und lädt nur ausdrücklich ausgewählte Bilder herunter. Die App enthält keine Werbung, kein Tracking und keine Analysedienste. Bilder werden nicht hochgeladen oder an andere Server weitergegeben.
|
||||||
|
|
||||||
|
Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md).
|
||||||
|
|
||||||
|
## Veröffentlichungen
|
||||||
|
|
||||||
|
Für ein Gitea-Release genügt ein neuer, höherer Versions-Tag wie `v0.1.1`; die
|
||||||
|
Pipeline übernimmt daraus alle Versionen für den Build. Für dauerhaft im
|
||||||
|
Repository gepflegte Versionsstände und F-Droid-Releases sollten die Versionen
|
||||||
|
in `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml` und
|
||||||
|
`.fdroid.yml` zusätzlich im Quellcode aktualisiert werden.
|
||||||
|
|
||||||
|
Die Metadaten für F-Droid liegen unter `fastlane/metadata/android`. Das Buildrezept für F-Droid befindet sich in `.fdroid.yml`.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Copyright (C) 2026 Christoph
|
||||||
|
|
||||||
|
WallpaperFlow ist freie Software und steht unter der GNU General Public License Version 3.0 (`GPL-3.0-only`). Die vollständigen Lizenzbedingungen stehen in [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
<rect width="512" height="512" rx="116" fill="#145d32"/>
|
<rect width="512" height="512" rx="116" fill="#edf4e9"/>
|
||||||
<path d="M142 128h228a42 42 0 0 1 42 42v210a42 42 0 0 1-42 42H142a42 42 0 0 1-42-42V170a42 42 0 0 1 42-42Z" fill="#f8faf7" opacity=".22"/>
|
<circle cx="256" cy="256" r="177" fill="#0f5b32"/>
|
||||||
<path d="M172 92h190a38 38 0 0 1 38 38v214a38 38 0 0 1-38 38H172a38 38 0 0 1-38-38V130a38 38 0 0 1 38-38Z" fill="#f8faf7"/>
|
<path d="M143 191c24-45 66-77 116-86" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
<circle cx="321" cy="170" r="31" fill="#a9cda7"/>
|
<path d="m238 93 31 10-25 20Z" fill="#91bf93"/>
|
||||||
<path d="m157 319 72-91 53 62 37-42 65 79v17a17 17 0 0 1-17 17H168a17 17 0 0 1-17-17v-15Z" fill="#145d32"/>
|
<path d="m146 211 55 128 55-103 55 103 55-128" fill="none" stroke="#f8faf4" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M366 112a122 122 0 0 1 39 61" fill="none" stroke="#a9cda7" stroke-width="18" stroke-linecap="round"/>
|
<circle cx="366" cy="177" r="24" fill="#f2c85b"/>
|
||||||
|
<path d="M369 365c-25 28-59 47-97 52" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m291 426-30-8 23-21Z" fill="#91bf93"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 671 B After Width: | Height: | Size: 703 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#edf4e9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 120 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<circle cx="256" cy="256" r="174" fill="#0f5b32"/>
|
||||||
|
<path d="M145 191c24-44 65-75 114-84" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m238 95 30 10-24 20Z" fill="#91bf93"/>
|
||||||
|
<path d="m149 213 53 124 54-101 54 101 53-124" fill="none" stroke="#f8faf4" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="364" cy="179" r="23" fill="#f2c85b"/>
|
||||||
|
<path d="M367 363c-24 27-58 45-95 50" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m291 422-30-8 23-21Z" fill="#91bf93"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 644 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<path d="m149 213 53 124 54-101 54 101 53-124" fill="none" stroke="#000" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M145 191c24-44 65-75 114-84m108 256c-24 27-58 45-95 50" fill="none" stroke="#000" stroke-width="22" stroke-linecap="round"/>
|
||||||
|
<path d="m238 95 30 10-24 20Zm53 327-30-8 23-21Z" fill="#000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"default": "../../app-icon.svg",
|
||||||
|
"bg_color": "#edf4e9",
|
||||||
|
"android_bg": "android-background.svg",
|
||||||
|
"android_fg": "android-foreground.svg",
|
||||||
|
"android_fg_scale": 82,
|
||||||
|
"android_monochrome": "android-monochrome.svg"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="116" fill="#0f5b32"/>
|
||||||
|
<g transform="rotate(-8 236 258)">
|
||||||
|
<rect x="112" y="111" width="250" height="316" rx="48" fill="#8fbd91"/>
|
||||||
|
</g>
|
||||||
|
<rect x="142" y="78" width="258" height="330" rx="49" fill="#f8faf4"/>
|
||||||
|
<circle cx="326" cy="153" r="30" fill="#f2c85b"/>
|
||||||
|
<path d="M163 330 238 226l54 69 38-44 51 65v54c0 11-9 20-20 20H181c-11 0-20-9-20-20Z" fill="#0f5b32"/>
|
||||||
|
<path d="M342 74c39 12 69 42 82 80" fill="none" stroke="#dcebd8" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
<path d="m408 128 18 30 11-33Z" fill="#dcebd8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 640 B |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="116" fill="#143d2c"/>
|
||||||
|
<path d="M169 205v-42c0-55 38-94 87-94s87 39 87 94v42" fill="none" stroke="#dcebd8" stroke-width="35" stroke-linecap="round"/>
|
||||||
|
<rect x="103" y="182" width="306" height="254" rx="58" fill="#f8faf4"/>
|
||||||
|
<circle cx="328" cy="251" r="29" fill="#f2c85b"/>
|
||||||
|
<path d="m124 367 86-104 57 68 40-47 81 92v18c0 12-10 22-22 22H146c-12 0-22-10-22-22Z" fill="#16804a"/>
|
||||||
|
<circle cx="256" cy="330" r="24" fill="#143d2c"/>
|
||||||
|
<path d="M245 344h22l10 42h-42Z" fill="#143d2c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="116" fill="#edf4e9"/>
|
||||||
|
<circle cx="256" cy="256" r="177" fill="#0f5b32"/>
|
||||||
|
<path d="M143 191c24-45 66-77 116-86" fill="none" stroke="#8fbd91" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m238 93 30 10-24 20Z" fill="#8fbd91"/>
|
||||||
|
<path d="m146 211 55 128 55-103 55 103 55-128" fill="none" stroke="#f8faf4" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="366" cy="177" r="24" fill="#f2c85b"/>
|
||||||
|
<path d="M369 365c-25 28-59 47-97 52" fill="none" stroke="#8fbd91" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m291 426-29-8 22-21Z" fill="#8fbd91"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 703 B |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="116" fill="#0b5430"/>
|
||||||
|
<circle cx="256" cy="256" r="166" fill="none" stroke="#80b688" stroke-width="22" stroke-dasharray="250 70" stroke-linecap="round"/>
|
||||||
|
<path d="m390 136 25 4-13 22Z" fill="#80b688"/>
|
||||||
|
<path d="m116 362-24-7 16-20Z" fill="#80b688"/>
|
||||||
|
<g transform="rotate(7 256 256)">
|
||||||
|
<rect x="165" y="111" width="182" height="290" rx="42" fill="#f8faf4"/>
|
||||||
|
<circle cx="296" cy="179" r="24" fill="#f2c85b"/>
|
||||||
|
<path d="m181 326 56-76 42 50 26-30 27 36v58c0 12-10 21-21 21H202c-12 0-21-10-21-21Z" fill="#16804a"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1600 620">
|
||||||
|
<rect width="1600" height="620" fill="#edf1ed"/>
|
||||||
|
<text x="70" y="70" font-family="Inter,Arial,sans-serif" font-size="34" font-weight="800" fill="#18201b">WallpaperFlow · Icon-Richtungen</text>
|
||||||
|
<text x="70" y="105" font-family="Inter,Arial,sans-serif" font-size="17" fill="#657068">Vier Varianten in der bestehenden Grün-/Salbei-Farbwelt</text>
|
||||||
|
<g font-family="Inter,Arial,sans-serif">
|
||||||
|
<g transform="translate(48 140)">
|
||||||
|
<rect width="350" height="430" rx="30" fill="#f8faf7"/>
|
||||||
|
<image x="45" y="35" width="260" height="260" xlink:href="01-frame-flow.svg"/>
|
||||||
|
<image x="274" y="270" width="58" height="58" xlink:href="01-frame-flow.svg"/>
|
||||||
|
<text x="32" y="344" font-size="23" font-weight="800" fill="#18201b">1 · Frame Flow</text>
|
||||||
|
<text x="32" y="375" font-size="15" fill="#657068">Foto im Mittelpunkt, Wechselbewegung</text>
|
||||||
|
<text x="32" y="400" font-size="15" fill="#657068">modern und sofort verständlich</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(433 140)">
|
||||||
|
<rect width="350" height="430" rx="30" fill="#f8faf7"/>
|
||||||
|
<image x="45" y="35" width="260" height="260" xlink:href="02-lockscape.svg"/>
|
||||||
|
<image x="274" y="270" width="58" height="58" xlink:href="02-lockscape.svg"/>
|
||||||
|
<text x="32" y="344" font-size="23" font-weight="800" fill="#18201b">2 · Lockscape</text>
|
||||||
|
<text x="32" y="375" font-size="15" fill="#657068">Sperrbildschirm eindeutig erkennbar</text>
|
||||||
|
<text x="32" y="400" font-size="15" fill="#657068">kräftig, funktional und unverwechselbar</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(818 140)">
|
||||||
|
<rect width="350" height="430" rx="30" fill="#f8faf7"/>
|
||||||
|
<image x="45" y="35" width="260" height="260" xlink:href="03-wechsel-w.svg"/>
|
||||||
|
<image x="274" y="270" width="58" height="58" xlink:href="03-wechsel-w.svg"/>
|
||||||
|
<text x="32" y="344" font-size="23" font-weight="800" fill="#18201b">3 · Wechsel-W</text>
|
||||||
|
<text x="32" y="375" font-size="15" fill="#657068">Eigenständige Markenform mit W</text>
|
||||||
|
<text x="32" y="400" font-size="15" fill="#657068">reduziert und gut als Logo ausbaubar</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(1203 140)">
|
||||||
|
<rect width="350" height="430" rx="30" fill="#f8faf7"/>
|
||||||
|
<image x="45" y="35" width="260" height="260" xlink:href="04-orbit-gallery.svg"/>
|
||||||
|
<image x="274" y="270" width="58" height="58" xlink:href="04-orbit-gallery.svg"/>
|
||||||
|
<text x="32" y="344" font-size="23" font-weight="800" fill="#18201b">4 · Orbit Gallery</text>
|
||||||
|
<text x="32" y="375" font-size="15" fill="#657068">Dynamischer automatischer Wechsel</text>
|
||||||
|
<text x="32" y="400" font-size="15" fill="#657068">technischer und energiegeladener</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Erste öffentliche Version mit automatischem Bildwechsel, lokaler Galerie, Mehrfachauswahl, Immich-Import, Intervallsteuerung und mehrsprachiger Oberfläche.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
WallpaperFlow wechselt automatisch zwischen deinen ausgewählten Bildern und setzt sie als Sperrbildschirm- oder Hintergrundbild.
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
* beliebig viele Bilder aus der Android-Auswahl importieren
|
||||||
|
* Bilder alle 5, 15 oder 30 Minuten sowie stündlich wechseln
|
||||||
|
* zufällige oder fortlaufende Reihenfolge
|
||||||
|
* nur den Sperrbildschirm oder das allgemeine Hintergrundbild ändern
|
||||||
|
* Bilder gemeinsam auswählen und löschen
|
||||||
|
* Bilder aus einem eigenen Immich-Server auswählen und lokal importieren
|
||||||
|
* Wechsel nach einem Neustart automatisch fortsetzen
|
||||||
|
* Oberfläche in mehreren europäischen und asiatischen Sprachen
|
||||||
|
|
||||||
|
Importierte Bilder und Einstellungen bleiben lokal auf deinem Gerät. Die optionale Immich-Verbindung kommuniziert nur mit deinem eingetragenen Server. WallpaperFlow enthält keine Werbung, kein Tracking und keine Analysedienste.
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 945 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Automatischer Wechsel deiner Sperrbildschirmbilder – lokal und privat
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
WallpaperFlow
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
First public release with automatic rotation, a local gallery, multi-selection, Immich import, configurable intervals and a multilingual interface.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
WallpaperFlow automatically rotates through your selected images and sets them as your lock-screen or general wallpaper.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* import any number of images using the Android system picker
|
||||||
|
* change images every 5, 15 or 30 minutes or every few hours
|
||||||
|
* shuffle or sequential order
|
||||||
|
* change only the lock screen or the general wallpaper
|
||||||
|
* select and delete several images at once
|
||||||
|
* select images from your own Immich server and import them locally
|
||||||
|
* resume rotation after a device restart
|
||||||
|
* interface in multiple European and Asian languages
|
||||||
|
|
||||||
|
Imported images and settings remain locally on your device. The optional Immich connection communicates only with the server you configure. WallpaperFlow contains no advertising, tracking or analytics services.
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 943 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Automatically rotate lock-screen wallpapers locally and privately
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
WallpaperFlow
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#f8faf7" />
|
<meta name="theme-color" content="#f8faf7" />
|
||||||
<title>WallpaperFlow</title>
|
<title>WallpaperFlow</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "lockscreenwallpaper",
|
"name": "lockscreenwallpaper",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "lockscreenwallpaper",
|
"name": "lockscreenwallpaper",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "tauri-plugin-wallpaper"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
authors = [ "LockScreenWallpaper" ]
|
authors = [ "LockScreenWallpaper" ]
|
||||||
description = ""
|
description = ""
|
||||||
|
license = "GPL-3.0-only"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.77.2"
|
||||||
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies {
|
|||||||
implementation("androidx.core:core-ktx:1.9.0")
|
implementation("androidx.core:core-ktx:1.9.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||||
implementation("com.google.android.material:material:1.7.0")
|
implementation("com.google.android.material:material:1.7.0")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.10.1")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<application>
|
<application>
|
||||||
<service android:name="de.wechselbild.wallpaper.WallpaperRotationService" android:exported="false" android:foregroundServiceType="specialUse">
|
<service android:name="de.wechselbild.wallpaper.ScreenOnRotationService" android:exported="false" android:foregroundServiceType="specialUse">
|
||||||
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper when the display turns on" />
|
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper when the screen turns on" />
|
||||||
</service>
|
</service>
|
||||||
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
|
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
|
||||||
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<receiver android:name="de.wechselbild.wallpaper.WallpaperAlarmReceiver" android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import android.content.Intent
|
|||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED && WallpaperStore.enabled(context)) runCatching { WallpaperRotationService.start(context) }
|
if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return
|
||||||
|
when {
|
||||||
|
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
|
||||||
|
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
|
||||||
|
}
|
||||||
|
if (ImmichClient.configured(context) && WallpaperStore.prefetchImmich(context)) ImmichPrefetchWorker.enqueue(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import app.tauri.plugin.JSArray
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
object ImmichClient {
|
||||||
|
private const val PREFS = "wechselbild"
|
||||||
|
private const val KEY_SERVER_URL = "immich_server_url"
|
||||||
|
private const val KEY_USER_NAME = "immich_user_name"
|
||||||
|
private const val KEY_API_KEY_DATA = "immich_api_key_data"
|
||||||
|
private const val KEY_API_KEY_IV = "immich_api_key_iv"
|
||||||
|
private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
|
||||||
|
private const val CACHE_MAX_BYTES = 512L * 1024 * 1024
|
||||||
|
private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024
|
||||||
|
private const val PROGRESS_STEP_BYTES = 256L * 1024
|
||||||
|
private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
|
||||||
|
private val thumbnailPool = Executors.newFixedThreadPool(4)
|
||||||
|
private val importProgress = AtomicReference(ImportProgress())
|
||||||
|
|
||||||
|
private data class Credentials(val serverUrl: String, val apiKey: String)
|
||||||
|
private data class ImportProgress(
|
||||||
|
val active: Boolean = false,
|
||||||
|
val completed: Int = 0,
|
||||||
|
val total: Int = 0,
|
||||||
|
val bytesDownloaded: Long = 0,
|
||||||
|
val bytesTotal: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private fun normalizeServerUrl(value: String): String {
|
||||||
|
val raw = value.trim().trimEnd('/')
|
||||||
|
require(raw.isNotBlank()) { "Bitte gib die URL deines Immich-Servers ein." }
|
||||||
|
val uri = runCatching { URI(raw) }.getOrNull()
|
||||||
|
?: throw IllegalArgumentException("Die Immich-URL ist ungültig.")
|
||||||
|
require(uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) {
|
||||||
|
"Die Immich-URL muss mit https:// oder http:// beginnen."
|
||||||
|
}
|
||||||
|
require(!uri.host.isNullOrBlank()) { "Die Immich-URL enthält keinen gültigen Hostnamen." }
|
||||||
|
require(uri.userInfo == null && uri.query == null && uri.fragment == null) { "Die Immich-URL ist ungültig." }
|
||||||
|
val basePath = (uri.path ?: "").trimEnd('/')
|
||||||
|
val apiPath = if (basePath.endsWith("/api")) basePath else "$basePath/api"
|
||||||
|
return URI(uri.scheme.lowercase(), null, uri.host, uri.port, apiPath, null, null).toString().trimEnd('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun secretKey(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||||
|
init(
|
||||||
|
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptApiKey(context: Context, value: String) {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, secretKey()) }
|
||||||
|
val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
prefs(context).edit()
|
||||||
|
.putString(KEY_API_KEY_DATA, Base64.encodeToString(encrypted, Base64.NO_WRAP))
|
||||||
|
.putString(KEY_API_KEY_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptApiKey(context: Context): String? = runCatching {
|
||||||
|
val preferences = prefs(context)
|
||||||
|
val encrypted = Base64.decode(preferences.getString(KEY_API_KEY_DATA, null), Base64.NO_WRAP)
|
||||||
|
val iv = Base64.decode(preferences.getString(KEY_API_KEY_IV, null), Base64.NO_WRAP)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||||
|
init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
|
||||||
|
}
|
||||||
|
String(cipher.doFinal(encrypted), StandardCharsets.UTF_8)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun credentials(context: Context): Credentials {
|
||||||
|
val serverUrl = prefs(context).getString(KEY_SERVER_URL, null).orEmpty()
|
||||||
|
val apiKey = decryptApiKey(context).orEmpty()
|
||||||
|
check(serverUrl.isNotBlank() && apiKey.isNotBlank()) { "Immich ist noch nicht verbunden." }
|
||||||
|
return Credentials(serverUrl, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectionObject(context: Context): JSObject {
|
||||||
|
val preferences = prefs(context)
|
||||||
|
val serverUrl = preferences.getString(KEY_SERVER_URL, "").orEmpty()
|
||||||
|
return JSObject().apply {
|
||||||
|
put("configured", serverUrl.isNotBlank() && !decryptApiKey(context).isNullOrBlank())
|
||||||
|
put("serverUrl", serverUrl)
|
||||||
|
put("userName", preferences.getString(KEY_USER_NAME, "").orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connection(context: Context) = connectionObject(context)
|
||||||
|
|
||||||
|
fun configured(context: Context): Boolean {
|
||||||
|
val preferences = prefs(context)
|
||||||
|
return preferences.getString(KEY_SERVER_URL, "").orEmpty().isNotBlank() && !decryptApiKey(context).isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
|
||||||
|
val normalized = normalizeServerUrl(serverUrl)
|
||||||
|
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
|
||||||
|
val user = requestJson(normalized, apiKey.trim(), "/users/me")
|
||||||
|
val userName = user.optString("name").ifBlank { user.optString("email") }.ifBlank { "Immich" }
|
||||||
|
encryptApiKey(context, apiKey.trim())
|
||||||
|
prefs(context).edit().putString(KEY_SERVER_URL, normalized).putString(KEY_USER_NAME, userName).apply()
|
||||||
|
return connectionObject(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect(context: Context): JSObject {
|
||||||
|
prefs(context).edit()
|
||||||
|
.remove(KEY_SERVER_URL)
|
||||||
|
.remove(KEY_USER_NAME)
|
||||||
|
.remove(KEY_API_KEY_DATA)
|
||||||
|
.remove(KEY_API_KEY_IV)
|
||||||
|
.apply()
|
||||||
|
return connectionObject(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openConnection(
|
||||||
|
serverUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
path: String,
|
||||||
|
method: String = "GET",
|
||||||
|
body: String? = null,
|
||||||
|
): HttpURLConnection {
|
||||||
|
val connection = URI(serverUrl + path).toURL().openConnection() as HttpURLConnection
|
||||||
|
connection.requestMethod = method
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 60_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
connection.setRequestProperty("Accept", "application/json, image/*, application/octet-stream")
|
||||||
|
connection.setRequestProperty("x-api-key", apiKey)
|
||||||
|
if (body != null) {
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) }
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorMessage(connection: HttpURLConnection): String {
|
||||||
|
val detail = runCatching {
|
||||||
|
connection.errorStream?.bufferedReader()?.use { it.readText().take(600) }.orEmpty()
|
||||||
|
}.getOrDefault("")
|
||||||
|
val message = runCatching { JSONObject(detail).optString("message") }.getOrDefault("")
|
||||||
|
return when (connection.responseCode) {
|
||||||
|
401, 403 -> "Immich hat den API-Key abgelehnt. Prüfe die Berechtigungen des Schlüssels."
|
||||||
|
404 -> "Die Immich-API wurde unter dieser URL nicht gefunden."
|
||||||
|
else -> message.ifBlank { "Immich antwortet mit HTTP ${connection.responseCode}." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestJson(serverUrl: String, apiKey: String, path: String, method: String = "GET", body: String? = null): JSONObject {
|
||||||
|
val connection = openConnection(serverUrl, apiKey, path, method, body)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
|
||||||
|
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
||||||
|
} finally { connection.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestArray(serverUrl: String, apiKey: String, path: String): JSONArray {
|
||||||
|
val connection = openConnection(serverUrl, apiKey, path)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
|
||||||
|
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
|
||||||
|
} finally { connection.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestBytes(serverUrl: String, apiKey: String, path: String): Pair<ByteArray, String> {
|
||||||
|
val connection = openConnection(serverUrl, apiKey, path)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
|
||||||
|
val bytes = connection.inputStream.use { input -> ByteArrayOutputStream().use { output -> input.copyTo(output); output.toByteArray() } }
|
||||||
|
return bytes to connection.contentType.orEmpty().substringBefore(';')
|
||||||
|
} finally { connection.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File, trackProgress: Boolean = true): String {
|
||||||
|
val connection = openConnection(serverUrl, apiKey, path)
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
|
||||||
|
val total = connection.contentLengthLong.coerceAtLeast(0)
|
||||||
|
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgressUpdate = 0L
|
||||||
|
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
target.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val count = input.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
downloaded += count
|
||||||
|
require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
|
||||||
|
if (trackProgress && (downloaded - lastProgressUpdate >= PROGRESS_STEP_BYTES || downloaded == total)) {
|
||||||
|
lastProgressUpdate = downloaded
|
||||||
|
importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connection.contentType.orEmpty().substringBefore(';')
|
||||||
|
} catch (error: Exception) {
|
||||||
|
target.delete()
|
||||||
|
throw error
|
||||||
|
} finally { connection.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun thumbnailDataUrl(credentials: Credentials, assetId: String): String = runCatching {
|
||||||
|
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
|
||||||
|
val (bytes, contentType) = requestBytes(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=thumbnail")
|
||||||
|
val mime = if (contentType.startsWith("image/")) contentType else "image/jpeg"
|
||||||
|
"data:$mime;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
|
fun albums(context: Context): JSObject {
|
||||||
|
val credentials = credentials(context)
|
||||||
|
val response = requestArray(credentials.serverUrl, credentials.apiKey, "/albums")
|
||||||
|
val albums = JSArray()
|
||||||
|
(0 until response.length()).map { response.getJSONObject(it) }
|
||||||
|
.sortedBy { it.optString("albumName").lowercase() }
|
||||||
|
.forEach { album ->
|
||||||
|
albums.put(JSObject().apply {
|
||||||
|
put("id", album.getString("id"))
|
||||||
|
put("name", album.optString("albumName", "Album"))
|
||||||
|
put("assetCount", album.optInt("assetCount", 0))
|
||||||
|
put("thumbnailUrl", "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return JSObject().apply { put("albums", albums) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assets(context: Context, albumId: String?, page: Int, size: Int): JSObject {
|
||||||
|
val credentials = credentials(context)
|
||||||
|
val safePage = page.coerceAtLeast(1)
|
||||||
|
val safeSize = size.coerceIn(1, 60)
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("type", "IMAGE")
|
||||||
|
put("page", safePage)
|
||||||
|
put("size", safeSize)
|
||||||
|
put("order", "desc")
|
||||||
|
if (!albumId.isNullOrBlank()) put("albumIds", JSONArray().put(albumId))
|
||||||
|
}
|
||||||
|
val response = requestJson(credentials.serverUrl, credentials.apiKey, "/search/metadata", "POST", body.toString())
|
||||||
|
.getJSONObject("assets")
|
||||||
|
val jsonItems = response.getJSONArray("items")
|
||||||
|
val raw = (0 until jsonItems.length()).map { jsonItems.getJSONObject(it) }
|
||||||
|
val thumbnails = thumbnailPool.invokeAll(raw.map { asset -> Callable { thumbnailDataUrl(credentials, asset.getString("id")) } })
|
||||||
|
val items = JSArray()
|
||||||
|
raw.forEachIndexed { index, asset ->
|
||||||
|
items.put(JSObject().apply {
|
||||||
|
put("id", asset.getString("id"))
|
||||||
|
put("fileName", asset.optString("originalFileName", "Immich image"))
|
||||||
|
put("thumbnailUrl", runCatching { thumbnails[index].get() }.getOrDefault(""))
|
||||||
|
put("takenAt", asset.optString("localDateTime", asset.optString("fileCreatedAt", "")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return JSObject().apply {
|
||||||
|
put("items", items)
|
||||||
|
put("page", safePage)
|
||||||
|
put("hasMore", !response.isNull("nextPage") && response.optString("nextPage").isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importOne(context: Context, credentials: Credentials, assetId: String) {
|
||||||
|
require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." }
|
||||||
|
if (WallpaperStore.hasImmich(context, credentials.serverUrl, assetId)) return
|
||||||
|
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
|
||||||
|
val temporary = File(context.cacheDir, ".immich-preview-$assetId.download")
|
||||||
|
downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
|
||||||
|
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat keine gültige Vorschau geliefert." }
|
||||||
|
try { WallpaperStore.addImmich(context, credentials.serverUrl, assetId, temporary) }
|
||||||
|
finally { temporary.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheDirectory(context: Context) = File(context.cacheDir, "immich-wallpaper-originals").apply { mkdirs() }
|
||||||
|
|
||||||
|
private fun cacheKey(serverUrl: String, assetId: String): String {
|
||||||
|
val server = MessageDigest.getInstance("SHA-256").digest(serverUrl.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
.take(6).joinToString("") { "%02x".format(it) }
|
||||||
|
return "$server-$assetId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validImage(file: File): Boolean {
|
||||||
|
if (!file.isFile || file.length() <= 0) return false
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun automaticDownloadsAllowed(context: Context, allowMetered: Boolean): Boolean {
|
||||||
|
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
return connectivity.activeNetworkInfo?.isConnected == true && (allowMetered || !connectivity.isActiveNetworkMetered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
|
||||||
|
val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
|
||||||
|
if (validImage(target)) {
|
||||||
|
target.setLastModified(System.currentTimeMillis())
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
target.delete()
|
||||||
|
if (!allowNetwork) return null
|
||||||
|
val current = runCatching { credentials(context) }.getOrNull() ?: return null
|
||||||
|
if (current.serverUrl != serverUrl || !assetIdPattern.matches(assetId)) return null
|
||||||
|
val temporary = File(cacheDirectory(context), ".${cacheKey(serverUrl, assetId)}.download")
|
||||||
|
val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
|
||||||
|
return try {
|
||||||
|
downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/original", temporary, trackProgress = false)
|
||||||
|
if (!validImage(temporary)) {
|
||||||
|
temporary.delete()
|
||||||
|
downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary, trackProgress = false)
|
||||||
|
}
|
||||||
|
if (!validImage(temporary)) return null
|
||||||
|
if (!temporary.renameTo(target)) { temporary.copyTo(target, overwrite = true); temporary.delete() }
|
||||||
|
target.setLastModified(System.currentTimeMillis())
|
||||||
|
trimCache(context, target)
|
||||||
|
target
|
||||||
|
} catch (_: Exception) {
|
||||||
|
temporary.delete()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun deleteCachedOriginal(context: Context, serverUrl: String, assetId: String) {
|
||||||
|
val key = cacheKey(serverUrl, assetId)
|
||||||
|
File(cacheDirectory(context), "$key.image").delete()
|
||||||
|
File(cacheDirectory(context), ".$key.download").delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimCache(context: Context, protected: File) {
|
||||||
|
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.lastModified() } ?: return
|
||||||
|
var bytes = files.sumOf { it.length() }
|
||||||
|
for (file in files) {
|
||||||
|
if (bytes <= CACHE_MAX_BYTES) break
|
||||||
|
if (file != protected) {
|
||||||
|
val length = file.length()
|
||||||
|
if (file.delete()) bytes -= length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importAssets(context: Context, ids: List<String>): JSObject {
|
||||||
|
require(ids.isNotEmpty()) { "Wähle mindestens ein Immich-Bild aus." }
|
||||||
|
require(ids.size <= 100) { "Bitte importiere höchstens 100 Bilder auf einmal." }
|
||||||
|
val credentials = credentials(context)
|
||||||
|
val uniqueIds = ids.distinct()
|
||||||
|
importProgress.set(ImportProgress(active = true, total = uniqueIds.size))
|
||||||
|
try {
|
||||||
|
uniqueIds.forEachIndexed { index, id ->
|
||||||
|
importOne(context, credentials, id)
|
||||||
|
importProgress.updateAndGet { it.copy(completed = index + 1, bytesDownloaded = 0, bytesTotal = 0) }
|
||||||
|
}
|
||||||
|
return WallpaperStore.state(context)
|
||||||
|
} finally {
|
||||||
|
importProgress.updateAndGet { it.copy(active = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importProgress(): JSObject = importProgress.get().let { progress ->
|
||||||
|
JSObject().apply {
|
||||||
|
put("active", progress.active)
|
||||||
|
put("completed", progress.completed)
|
||||||
|
put("total", progress.total)
|
||||||
|
put("bytesDownloaded", progress.bytesDownloaded)
|
||||||
|
put("bytesTotal", progress.bytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ImmichPrefetchWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
WallpaperStore.prefetchImmichOriginals(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "immich-wifi-prefetch"
|
||||||
|
|
||||||
|
fun enqueue(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build()
|
||||||
|
val request = PeriodicWorkRequestBuilder<ImmichPrefetchWorker>(6, TimeUnit.HOURS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
class ScreenOnRotationService : Service() {
|
||||||
|
private val worker = Executors.newSingleThreadExecutor()
|
||||||
|
private val rotating = AtomicBoolean(false)
|
||||||
|
private val changedWhileScreenOff = AtomicBoolean(false)
|
||||||
|
private val screenReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (!WallpaperStore.screenOnEnabled(context)) return
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_SCREEN_OFF -> rotate(context, preparedForScreenOn = true)
|
||||||
|
Intent.ACTION_SCREEN_ON -> {
|
||||||
|
if (!changedWhileScreenOff.getAndSet(false)) rotate(context, preparedForScreenOn = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotate(context: Context, preparedForScreenOn: Boolean) {
|
||||||
|
if (!rotating.compareAndSet(false, true)) return
|
||||||
|
worker.execute {
|
||||||
|
try {
|
||||||
|
if (WallpaperStore.applyNext(context, automatic = true) && preparedForScreenOn) {
|
||||||
|
changedWhileScreenOff.set(true)
|
||||||
|
}
|
||||||
|
} finally { rotating.set(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_gallery)
|
||||||
|
.setContentTitle(getString(R.string.wallpaper_service_title))
|
||||||
|
.setContentText(getString(R.string.wallpaper_service_text))
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(pending)
|
||||||
|
.build()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
registerReceiver(screenReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
registerReceiver(screenReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (!WallpaperStore.screenOnEnabled(this)) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
runCatching { unregisterReceiver(screenReceiver) }
|
||||||
|
worker.shutdown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "wechselbild_screen_on"
|
||||||
|
private const val NOTIFICATION_ID = 4217
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(context, Intent(context, ScreenOnRotationService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, ScreenOnRotationService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
|
val channel = NotificationChannel(CHANNEL, getString(R.string.wallpaper_channel_name), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
class WallpaperAlarmReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (!WallpaperStore.timedEnabled(context)) {
|
||||||
|
WallpaperScheduler.cancel(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (WallpaperScheduler.claimIfDue(context)) {
|
||||||
|
WallpaperRotationWorker.enqueue(context)
|
||||||
|
}
|
||||||
|
WallpaperScheduler.ensureScheduled(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import app.tauri.annotation.Command
|
|||||||
import app.tauri.annotation.InvokeArg
|
import app.tauri.annotation.InvokeArg
|
||||||
import app.tauri.annotation.TauriPlugin
|
import app.tauri.annotation.TauriPlugin
|
||||||
import app.tauri.plugin.Invoke
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSArray
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
import app.tauri.plugin.Plugin
|
import app.tauri.plugin.Plugin
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -17,12 +19,21 @@ import java.util.concurrent.Executors
|
|||||||
@InvokeArg
|
@InvokeArg
|
||||||
class SettingArgs { lateinit var name: String; var value: Boolean = false }
|
class SettingArgs { lateinit var name: String; var value: Boolean = false }
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class IntervalArgs { var minutes: Int = 0 }
|
||||||
|
|
||||||
@InvokeArg
|
@InvokeArg
|
||||||
class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
|
class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
|
||||||
|
|
||||||
@InvokeArg
|
@InvokeArg
|
||||||
class DeleteImageArgs { lateinit var id: String }
|
class DeleteImageArgs { lateinit var id: String }
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class ApplyWallpaperArgs { lateinit var id: String }
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class DeleteImagesArgs { var ids: Array<String> = emptyArray() }
|
||||||
|
|
||||||
@InvokeArg
|
@InvokeArg
|
||||||
class ImageCropArgs {
|
class ImageCropArgs {
|
||||||
lateinit var id: String
|
lateinit var id: String
|
||||||
@@ -30,13 +41,25 @@ class ImageCropArgs {
|
|||||||
var zoom: Double = 1.0
|
var zoom: Double = 1.0
|
||||||
var positionX: Double = 0.5
|
var positionX: Double = 0.5
|
||||||
var positionY: Double = 0.5
|
var positionY: Double = 0.5
|
||||||
|
var rotation: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class ImmichConnectArgs { lateinit var serverUrl: String; lateinit var apiKey: String }
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class ImmichAssetsArgs { var albumId: String? = null; var page: Int = 1; var size: Int = 30 }
|
||||||
|
|
||||||
|
@InvokeArg
|
||||||
|
class ImmichImportArgs { var ids: Array<String> = emptyArray() }
|
||||||
|
|
||||||
@TauriPlugin
|
@TauriPlugin
|
||||||
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
private val io = Executors.newSingleThreadExecutor()
|
private val io = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
@Command fun getState(invoke: Invoke) = io.execute { invoke.resolve(WallpaperStore.state(activity)) }
|
@Command fun getState(invoke: Invoke) {
|
||||||
|
io.execute { invoke.resolve(WallpaperStore.state(activity)) }
|
||||||
|
}
|
||||||
|
|
||||||
@Command fun getGallery(invoke: Invoke) = io.execute {
|
@Command fun getGallery(invoke: Invoke) = io.execute {
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +86,23 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
|||||||
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
|
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Command fun getImageIds(invoke: Invoke) = io.execute {
|
||||||
|
val ids = JSArray().apply { WallpaperStore.imageIds(activity).forEach { put(it) } }
|
||||||
|
invoke.resolve(JSObject().apply { put("ids", ids) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun deleteImages(invoke: Invoke) = io.execute {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(DeleteImagesArgs::class.java)
|
||||||
|
if (WallpaperStore.deleteMany(activity, args.ids.toList()) == 0) throw IllegalArgumentException("Keine Bilder gefunden")
|
||||||
|
invoke.resolve(WallpaperStore.state(activity))
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Bilder konnten nicht gelöscht werden") }
|
||||||
|
}
|
||||||
|
|
||||||
@Command fun setImageCrop(invoke: Invoke) = io.execute {
|
@Command fun setImageCrop(invoke: Invoke) = io.execute {
|
||||||
try {
|
try {
|
||||||
val args = invoke.parseArgs(ImageCropArgs::class.java)
|
val args = invoke.parseArgs(ImageCropArgs::class.java)
|
||||||
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY)
|
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY, args.rotation)
|
||||||
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
|
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
|
||||||
invoke.resolve(image)
|
invoke.resolve(image)
|
||||||
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
|
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
|
||||||
@@ -96,14 +132,75 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
|||||||
try {
|
try {
|
||||||
val args = invoke.parseArgs(SettingArgs::class.java)
|
val args = invoke.parseArgs(SettingArgs::class.java)
|
||||||
WallpaperStore.set(activity, args.name, args.value)
|
WallpaperStore.set(activity, args.name, args.value)
|
||||||
if (args.name == "enabled") {
|
|
||||||
if (args.value) WallpaperRotationService.start(activity) else WallpaperRotationService.stop(activity)
|
|
||||||
}
|
|
||||||
invoke.resolve(WallpaperStore.state(activity))
|
invoke.resolve(WallpaperStore.state(activity))
|
||||||
} catch (error: Exception) { invoke.reject(error.message ?: "Einstellung konnte nicht gespeichert werden") }
|
} catch (error: Exception) { invoke.reject(error.message ?: "Einstellung konnte nicht gespeichert werden") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Command fun setInterval(invoke: Invoke) {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(IntervalArgs::class.java)
|
||||||
|
WallpaperStore.setInterval(activity, args.minutes)
|
||||||
|
WallpaperScheduler.cancel(activity)
|
||||||
|
ScreenOnRotationService.stop(activity)
|
||||||
|
if (args.minutes == -1) ScreenOnRotationService.start(activity)
|
||||||
|
else if (args.minutes > 0) WallpaperScheduler.scheduleNext(activity)
|
||||||
|
invoke.resolve(WallpaperStore.state(activity))
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
|
||||||
|
}
|
||||||
|
|
||||||
@Command fun nextWallpaper(invoke: Invoke) = io.execute {
|
@Command fun nextWallpaper(invoke: Invoke) = io.execute {
|
||||||
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus")
|
try {
|
||||||
|
if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity))
|
||||||
|
else invoke.reject("Kein verfügbares Bild konnte angewendet werden")
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun applyWallpaper(invoke: Invoke) = io.execute {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(ApplyWallpaperArgs::class.java)
|
||||||
|
if (WallpaperStore.apply(activity, args.id)) invoke.resolve(WallpaperStore.state(activity))
|
||||||
|
else invoke.reject("Bild konnte nicht angewendet werden")
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun getImmichConnection(invoke: Invoke) = io.execute {
|
||||||
|
invoke.resolve(ImmichClient.connection(activity))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun connectImmich(invoke: Invoke) = io.execute {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
|
||||||
|
val result = ImmichClient.connect(activity, args.serverUrl, args.apiKey)
|
||||||
|
if (WallpaperStore.prefetchImmich(activity)) ImmichPrefetchWorker.enqueue(activity)
|
||||||
|
invoke.resolve(result)
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
|
||||||
|
ImmichPrefetchWorker.cancel(activity)
|
||||||
|
invoke.resolve(ImmichClient.disconnect(activity))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun getImmichAlbums(invoke: Invoke) = io.execute {
|
||||||
|
try { invoke.resolve(ImmichClient.albums(activity)) }
|
||||||
|
catch (error: Exception) { invoke.reject(error.message ?: "Immich-Alben konnten nicht geladen werden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun getImmichAssets(invoke: Invoke) = io.execute {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(ImmichAssetsArgs::class.java)
|
||||||
|
invoke.resolve(ImmichClient.assets(activity, args.albumId, args.page, args.size))
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Bilder konnten nicht geladen werden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun importImmichAssets(invoke: Invoke) = io.execute {
|
||||||
|
try {
|
||||||
|
val args = invoke.parseArgs(ImmichImportArgs::class.java)
|
||||||
|
invoke.resolve(ImmichClient.importAssets(activity, args.ids.toList()))
|
||||||
|
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Bilder konnten nicht importiert werden") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command fun getImmichImportProgress(invoke: Invoke) {
|
||||||
|
invoke.resolve(ImmichClient.importProgress())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package de.wechselbild.wallpaper
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
class WallpaperRotationService : Service() {
|
|
||||||
private val worker = Executors.newSingleThreadExecutor()
|
|
||||||
private val screenReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == Intent.ACTION_SCREEN_ON && WallpaperStore.enabled(context)) worker.execute { WallpaperStore.applyNext(context) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createChannel()
|
|
||||||
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_gallery)
|
|
||||||
.setContentTitle(getString(R.string.wallpaper_service_title))
|
|
||||||
.setContentText(getString(R.string.wallpaper_service_text))
|
|
||||||
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
ContextCompat.registerReceiver(this, screenReceiver, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_STICKY
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
override fun onDestroy() { runCatching { unregisterReceiver(screenReceiver) }; worker.shutdown(); super.onDestroy() }
|
|
||||||
|
|
||||||
private fun createChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= 26) {
|
|
||||||
val channel = NotificationChannel(CHANNEL, getString(R.string.wallpaper_channel_name), NotificationManager.IMPORTANCE_LOW)
|
|
||||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL = "wechselbild_rotation"
|
|
||||||
private const val NOTIFICATION_ID = 4217
|
|
||||||
fun start(context: Context) = ContextCompat.startForegroundService(context, Intent(context, WallpaperRotationService::class.java))
|
|
||||||
fun stop(context: Context) { context.stopService(Intent(context, WallpaperRotationService::class.java)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
if (!WallpaperStore.timedEnabled(applicationContext)) return Result.success()
|
||||||
|
WallpaperStore.applyNext(applicationContext, automatic = true)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "wallpaper-rotation"
|
||||||
|
|
||||||
|
fun enqueue(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
OneTimeWorkRequestBuilder<WallpaperRotationWorker>()
|
||||||
|
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
|
||||||
|
object WallpaperScheduler {
|
||||||
|
private const val PREFS = "wechselbild"
|
||||||
|
private const val KEY_NEXT_TRIGGER = "next_rotation_elapsed"
|
||||||
|
private const val REQUEST_CODE = 4218
|
||||||
|
|
||||||
|
fun ensureScheduled(context: Context) {
|
||||||
|
if (!WallpaperStore.timedEnabled(context)) {
|
||||||
|
cancel(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val storedTrigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L)
|
||||||
|
val interval = WallpaperStore.intervalMinutes(context) * 60_000L
|
||||||
|
if (storedTrigger <= now || storedTrigger > now + interval) scheduleNext(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun claimIfDue(context: Context): Boolean {
|
||||||
|
val preferences = preferences(context)
|
||||||
|
val trigger = preferences.getLong(KEY_NEXT_TRIGGER, 0L)
|
||||||
|
if (trigger <= 0L || trigger > SystemClock.elapsedRealtime() + 1_000L) return false
|
||||||
|
scheduleNext(context)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun scheduleNext(context: Context) {
|
||||||
|
val minutes = WallpaperStore.intervalMinutes(context)
|
||||||
|
if (minutes <= 0) {
|
||||||
|
cancel(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L
|
||||||
|
val alarmManager = context.getSystemService(AlarmManager::class.java)
|
||||||
|
val operation = operation(context)
|
||||||
|
val window = (minutes * 60_000L / 10).coerceIn(60_000L, 15 * 60_000L)
|
||||||
|
alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, trigger, window, operation)
|
||||||
|
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
context.getSystemService(AlarmManager::class.java).cancel(operation(context))
|
||||||
|
preferences(context).edit().remove(KEY_NEXT_TRIGGER).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun operation(context: Context) = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQUEST_CODE,
|
||||||
|
Intent(context, WallpaperAlarmReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun preferences(context: Context) =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
@@ -9,151 +9,304 @@ import android.graphics.Color
|
|||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.LruCache
|
||||||
import app.tauri.plugin.JSArray
|
import app.tauri.plugin.JSArray
|
||||||
import app.tauri.plugin.JSObject
|
import app.tauri.plugin.JSObject
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.random.Random
|
import java.security.MessageDigest
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
object WallpaperStore {
|
object WallpaperStore {
|
||||||
private const val PREFS = "wechselbild"
|
private const val PREFS = "wechselbild"
|
||||||
private const val KEY_INDEX = "current_index"
|
private const val KEY_INDEX = "current_index"
|
||||||
|
private const val KEY_CURRENT_ID = "current_entry_id"
|
||||||
|
private const val KEY_INTERVAL = "interval_minutes"
|
||||||
|
private const val KEY_SHUFFLE_SEEN_IDS = "shuffle_seen_ids"
|
||||||
private const val CROP_PREFIX = "crop_"
|
private const val CROP_PREFIX = "crop_"
|
||||||
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5)
|
private const val INVALID_PREFIX = "invalid_"
|
||||||
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
|
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
|
||||||
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
|
private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024
|
||||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
private const val HOME_PREVIEW_LIMIT = 12
|
||||||
|
private val thumbnailCache = LruCache<String, String>(48)
|
||||||
|
|
||||||
fun enabled(context: Context) = prefs(context).getBoolean("enabled", false)
|
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0)
|
||||||
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
private sealed class Entry {
|
||||||
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
abstract val id: String
|
||||||
|
abstract val preview: File
|
||||||
fun set(context: Context, name: String, value: Boolean) {
|
data class Local(val file: File) : Entry() { override val id = file.name; override val preview = file }
|
||||||
require(name in setOf("enabled", "shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
|
data class Immich(
|
||||||
prefs(context).edit().putBoolean(name, value).apply()
|
override val id: String,
|
||||||
|
val assetId: String,
|
||||||
|
val serverUrl: String,
|
||||||
|
val addedAt: Long,
|
||||||
|
val metadata: File,
|
||||||
|
override val preview: File,
|
||||||
|
) : Entry()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun state(context: Context): JSObject {
|
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
|
||||||
val originals = files(context)
|
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.name } ?: emptyList()
|
||||||
val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
|
private fun immichRoot(context: Context) = File(context.filesDir, "immich-wallpapers").apply { mkdirs() }
|
||||||
val previewFiles = if (index < 24) originals.take(24) else listOf(originals[index]) + originals.take(23)
|
private fun immichEntries(context: Context) = File(immichRoot(context), "entries").apply { mkdirs() }
|
||||||
val previewIndex = if (index < 24) index else 0
|
private fun immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() }
|
||||||
|
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
private fun renderedDirectory(context: Context) = File(context.cacheDir, "rendered-wallpapers").apply { mkdirs() }
|
||||||
|
private fun thumbnailDirectory(context: Context) = File(context.cacheDir, "wallpaper-thumbnails").apply { mkdirs() }
|
||||||
|
|
||||||
|
private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun virtualKey(serverUrl: String, assetId: String) = "${serverKey(serverUrl)}-$assetId"
|
||||||
|
private fun virtualId(serverUrl: String, assetId: String) = "immich:${serverKey(serverUrl)}:$assetId"
|
||||||
|
|
||||||
|
private fun entries(context: Context): List<Entry> {
|
||||||
|
val local = files(context).map { Entry.Local(it) }
|
||||||
|
val virtual = immichEntries(context).listFiles()?.filter { it.isFile && it.extension == "json" }?.mapNotNull { metadata ->
|
||||||
|
runCatching {
|
||||||
|
val json = JSONObject(metadata.readText())
|
||||||
|
val serverUrl = json.getString("serverUrl")
|
||||||
|
val assetId = json.getString("assetId")
|
||||||
|
val preview = File(immichPreviews(context), "${virtualKey(serverUrl, assetId)}.preview")
|
||||||
|
if (!preview.isFile) return@runCatching null
|
||||||
|
Entry.Immich(
|
||||||
|
id = virtualId(serverUrl, assetId),
|
||||||
|
assetId = assetId,
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
addedAt = json.optLong("addedAt", metadata.lastModified()),
|
||||||
|
metadata = metadata,
|
||||||
|
preview = preview,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}?.filterNotNull()?.sortedWith(compareBy<Entry.Immich> { it.addedAt }.thenBy { it.id }) ?: emptyList()
|
||||||
|
return local + virtual
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentIndex(context: Context, items: List<Entry>): Int {
|
||||||
|
if (items.isEmpty()) return 0
|
||||||
|
val preferences = prefs(context)
|
||||||
|
val currentId = preferences.getString(KEY_CURRENT_ID, null)
|
||||||
|
val byId = items.indexOfFirst { it.id == currentId }
|
||||||
|
if (byId >= 0) return byId
|
||||||
|
val legacy = preferences.getInt(KEY_INDEX, 0).coerceIn(0, items.lastIndex)
|
||||||
|
preferences.edit().putString(KEY_CURRENT_ID, items[legacy].id).apply()
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun intervalMinutes(context: Context): Int {
|
||||||
|
val preferences = prefs(context)
|
||||||
|
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
|
||||||
|
else if (preferences.getBoolean("enabled", false)) 30 else 0
|
||||||
|
}
|
||||||
|
fun enabled(context: Context) = intervalMinutes(context) != 0
|
||||||
|
fun timedEnabled(context: Context) = intervalMinutes(context) > 0
|
||||||
|
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
|
||||||
|
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
||||||
|
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
||||||
|
fun allowMobileData(context: Context) = prefs(context).getBoolean("allowMobileData", false)
|
||||||
|
fun prefetchImmich(context: Context) = prefs(context).getBoolean("prefetchImmich", true)
|
||||||
|
|
||||||
|
fun set(context: Context, name: String, value: Boolean) {
|
||||||
|
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
|
||||||
|
val editor = prefs(context).edit().putBoolean(name, value)
|
||||||
|
if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS)
|
||||||
|
editor.apply()
|
||||||
|
if (name == "prefetchImmich") {
|
||||||
|
if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context)
|
||||||
|
else if (!value) ImmichPrefetchWorker.cancel(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInterval(context: Context, minutes: Int) {
|
||||||
|
require(minutes == -1 || minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
|
||||||
|
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes != 0).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun state(context: Context, includePreviews: Boolean = true): JSObject {
|
||||||
|
val items = entries(context)
|
||||||
|
val index = currentIndex(context, items)
|
||||||
|
val previewItems = if (index < HOME_PREVIEW_LIMIT) items.take(HOME_PREVIEW_LIMIT) else listOf(items[index]) + items.take(HOME_PREVIEW_LIMIT - 1)
|
||||||
|
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0
|
||||||
return JSObject().apply {
|
return JSObject().apply {
|
||||||
put("imageCount", originals.size)
|
put("imageCount", items.size)
|
||||||
put("enabled", enabled(context))
|
put("enabled", enabled(context))
|
||||||
|
put("intervalMinutes", intervalMinutes(context))
|
||||||
put("shuffle", shuffle(context))
|
put("shuffle", shuffle(context))
|
||||||
put("lockScreenOnly", lockOnly(context))
|
put("lockScreenOnly", lockOnly(context))
|
||||||
|
put("allowMobileData", allowMobileData(context))
|
||||||
|
put("prefetchImmich", prefetchImmich(context))
|
||||||
put("currentIndex", previewIndex)
|
put("currentIndex", previewIndex)
|
||||||
|
put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
|
||||||
|
val ids = JSArray()
|
||||||
|
previewItems.forEach { ids.put(it.id) }
|
||||||
|
put("imageIds", ids)
|
||||||
val previews = JSArray()
|
val previews = JSArray()
|
||||||
previewFiles.forEach { previews.put(thumbnailDataUrl(it)) }
|
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
|
||||||
put("imageUrls", previews)
|
put("imageUrls", previews)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun gallery(context: Context, offset: Int, limit: Int): JSObject {
|
fun gallery(context: Context, offset: Int, limit: Int): JSObject {
|
||||||
val originals = files(context)
|
val items = entries(context)
|
||||||
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
|
val selectedIndex = currentIndex(context, items)
|
||||||
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(originals.size)
|
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size)
|
||||||
val safeLimit = limit.coerceIn(1, 100)
|
val safeLimit = limit.coerceIn(1, 100)
|
||||||
val items = JSArray()
|
val page = JSArray()
|
||||||
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
|
items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry ->
|
||||||
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
|
page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex))
|
||||||
}
|
}
|
||||||
return JSObject().apply {
|
return JSObject().apply { put("total", items.size); put("items", page) }
|
||||||
put("total", originals.size)
|
}
|
||||||
put("items", items)
|
|
||||||
|
@Synchronized
|
||||||
|
fun imageIds(context: Context) = entries(context).map { it.id }
|
||||||
|
|
||||||
|
fun prefetchImmichOriginals(context: Context) {
|
||||||
|
val unavailableServers = mutableSetOf<String>()
|
||||||
|
entries(context).filterIsInstance<Entry.Immich>().forEach { entry ->
|
||||||
|
if (entry.serverUrl in unavailableServers) return@forEach
|
||||||
|
if (ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork = true) == null) {
|
||||||
|
unavailableServers.add(entry.serverUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cropKey(id: String) = CROP_PREFIX + id
|
private fun cropKey(id: String) = CROP_PREFIX + id
|
||||||
|
private fun crop(context: Context, entry: Entry): CropSettings {
|
||||||
private fun crop(context: Context, file: File): CropSettings {
|
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
|
||||||
val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings()
|
|
||||||
return try {
|
return try {
|
||||||
val json = JSONObject(raw)
|
val json = JSONObject(raw)
|
||||||
CropSettings(
|
CropSettings(
|
||||||
mode = if (json.optString("mode") == "contain") "contain" else "cover",
|
mode = if (json.optString("mode") == "contain") "contain" else "cover",
|
||||||
zoom = json.optDouble("zoom", 1.0).coerceIn(0.35, 3.0),
|
zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0),
|
||||||
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
|
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
|
||||||
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
|
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
|
||||||
|
rotation = json.optInt("rotation", 0).let { ((it % 360) + 360) % 360 }.let { if (it % 90 == 0) it else 0 },
|
||||||
)
|
)
|
||||||
} catch (_: Exception) { CropSettings() }
|
} catch (_: Exception) { CropSettings() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject {
|
private fun galleryImage(context: Context, entry: Entry, selected: Boolean): JSObject {
|
||||||
val crop = crop(context, file)
|
val crop = crop(context, entry)
|
||||||
return JSObject().apply {
|
return JSObject().apply {
|
||||||
put("id", file.name)
|
put("id", entry.id)
|
||||||
put("url", thumbnailDataUrl(file))
|
put("url", thumbnailDataUrl(context, entry.preview))
|
||||||
put("selected", selected)
|
put("selected", selected)
|
||||||
put("cropMode", crop.mode)
|
put("cropMode", crop.mode)
|
||||||
put("cropZoom", crop.zoom)
|
put("cropZoom", crop.zoom)
|
||||||
put("cropPositionX", crop.x)
|
put("cropPositionX", crop.x)
|
||||||
put("cropPositionY", crop.y)
|
put("cropPositionY", crop.y)
|
||||||
|
put("cropRotation", crop.rotation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? {
|
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double, rotation: Int): JSObject? {
|
||||||
if (id.isBlank() || File(id).name != id) return null
|
val items = entries(context)
|
||||||
val file = files(context).firstOrNull { it.name == id } ?: return null
|
val entry = items.firstOrNull { it.id == id } ?: return null
|
||||||
val normalized = CropSettings(
|
val normalized = CropSettings(
|
||||||
mode = if (mode == "contain") "contain" else "cover",
|
mode = if (mode == "contain") "contain" else "cover",
|
||||||
zoom = zoom.coerceIn(0.35, 3.0),
|
zoom = zoom.coerceIn(1.0, 3.0), x = x.coerceIn(0.0, 1.0), y = y.coerceIn(0.0, 1.0),
|
||||||
x = x.coerceIn(0.0, 1.0),
|
rotation = ((rotation % 360) + 360) % 360,
|
||||||
y = y.coerceIn(0.0, 1.0),
|
|
||||||
)
|
)
|
||||||
|
require(normalized.rotation % 90 == 0) { "Ungültige Bilddrehung" }
|
||||||
val json = JSONObject().apply {
|
val json = JSONObject().apply {
|
||||||
put("mode", normalized.mode)
|
put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation)
|
||||||
put("zoom", normalized.zoom)
|
|
||||||
put("x", normalized.x)
|
|
||||||
put("y", normalized.y)
|
|
||||||
}
|
}
|
||||||
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
|
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
|
||||||
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0)
|
return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items))
|
||||||
return galleryImage(context, file, files(context).indexOf(file) == selectedIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun delete(context: Context, id: String): Boolean {
|
fun addImmich(context: Context, serverUrl: String, assetId: String, previewSource: File): Boolean {
|
||||||
if (id.isBlank() || File(id).name != id) return false
|
val key = virtualKey(serverUrl, assetId)
|
||||||
val originals = files(context)
|
val metadata = File(immichEntries(context), "$key.json")
|
||||||
val position = originals.indexOfFirst { it.name == id }
|
val preview = File(immichPreviews(context), "$key.preview")
|
||||||
if (position < 0 || !originals[position].delete()) return false
|
if (metadata.isFile && preview.isFile) return false
|
||||||
|
val previewTemp = File(immichPreviews(context), ".$key.preview.tmp")
|
||||||
val preferences = prefs(context)
|
val metadataTemp = File(immichEntries(context), ".$key.json.tmp")
|
||||||
val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
|
try {
|
||||||
val remaining = originals.size - 1
|
previewSource.copyTo(previewTemp, overwrite = true)
|
||||||
val nextIndex = when {
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
remaining <= 0 -> 0
|
BitmapFactory.decodeFile(previewTemp.absolutePath, bounds)
|
||||||
position < previousIndex -> previousIndex - 1
|
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich-Vorschau konnte nicht gelesen werden." }
|
||||||
previousIndex >= remaining -> remaining - 1
|
if (!previewTemp.renameTo(preview)) { previewTemp.copyTo(preview, overwrite = true); previewTemp.delete() }
|
||||||
else -> previousIndex
|
metadataTemp.writeText(JSONObject().apply {
|
||||||
|
put("version", 1); put("serverUrl", serverUrl); put("assetId", assetId); put("addedAt", System.currentTimeMillis())
|
||||||
|
}.toString())
|
||||||
|
if (!metadataTemp.renameTo(metadata)) { metadataTemp.copyTo(metadata, overwrite = true); metadataTemp.delete() }
|
||||||
|
return true
|
||||||
|
} catch (error: Exception) {
|
||||||
|
previewTemp.delete()
|
||||||
|
metadataTemp.delete()
|
||||||
|
if (!metadata.isFile) preview.delete()
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
preferences.edit().remove(cropKey(id)).putInt(KEY_INDEX, nextIndex).apply()
|
}
|
||||||
return true
|
|
||||||
|
fun hasImmich(context: Context, serverUrl: String, assetId: String) = File(immichEntries(context), "${virtualKey(serverUrl, assetId)}.json").isFile
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun delete(context: Context, id: String) = deleteMany(context, listOf(id)) == 1
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun deleteMany(context: Context, ids: List<String>): Int {
|
||||||
|
val requested = ids.toSet()
|
||||||
|
val items = entries(context)
|
||||||
|
val current = items.getOrNull(currentIndex(context, items))
|
||||||
|
val deleted = items.filter { it.id in requested }.filter { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is Entry.Local -> entry.file.delete().also { if (it) removeThumbnail(entry.preview) }
|
||||||
|
is Entry.Immich -> {
|
||||||
|
entry.metadata.delete().also { removed ->
|
||||||
|
if (removed) {
|
||||||
|
removeThumbnail(entry.preview)
|
||||||
|
entry.preview.delete()
|
||||||
|
ImmichClient.deleteCachedOriginal(context, entry.serverUrl, entry.assetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted.isEmpty()) return 0
|
||||||
|
val editor = prefs(context).edit()
|
||||||
|
deleted.forEach { editor.remove(cropKey(it.id)) }
|
||||||
|
val remaining = entries(context)
|
||||||
|
val retained = current?.let { item -> remaining.indexOfFirst { it.id == item.id } } ?: -1
|
||||||
|
if (remaining.isEmpty()) editor.remove(KEY_CURRENT_ID).putInt(KEY_INDEX, 0)
|
||||||
|
else {
|
||||||
|
val next = if (retained >= 0) retained else currentIndex(context, items).coerceAtMost(remaining.lastIndex)
|
||||||
|
editor.putString(KEY_CURRENT_ID, remaining[next].id).putInt(KEY_INDEX, next)
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
return deleted.size
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap {
|
private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap {
|
||||||
val metrics = context.resources.displayMetrics
|
val metrics = context.resources.displayMetrics
|
||||||
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
|
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
|
||||||
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
|
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
|
||||||
val widthScale = targetWidth.toDouble() / source.width
|
val quarterTurn = crop.rotation == 90 || crop.rotation == 270
|
||||||
val heightScale = targetHeight.toDouble() / source.height
|
val rotatedWidth = if (quarterTurn) source.height else source.width
|
||||||
|
val rotatedHeight = if (quarterTurn) source.width else source.height
|
||||||
|
val widthScale = targetWidth.toDouble() / rotatedWidth
|
||||||
|
val heightScale = targetHeight.toDouble() / rotatedHeight
|
||||||
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
|
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
|
||||||
val scale = (baseScale * crop.zoom).toFloat()
|
val scale = (baseScale * crop.zoom).toFloat()
|
||||||
val scaledWidth = source.width * scale
|
val scaledWidth = rotatedWidth * scale
|
||||||
val scaledHeight = source.height * scale
|
val scaledHeight = rotatedHeight * scale
|
||||||
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
|
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
|
||||||
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
|
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
|
||||||
|
|
||||||
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
|
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
|
||||||
val canvas = Canvas(output)
|
val canvas = Canvas(output)
|
||||||
canvas.drawColor(Color.BLACK)
|
canvas.drawColor(Color.BLACK)
|
||||||
val matrix = Matrix().apply {
|
val matrix = Matrix().apply {
|
||||||
setScale(scale, scale)
|
postTranslate(-source.width / 2f, -source.height / 2f)
|
||||||
postTranslate(left, top)
|
postRotate(crop.rotation.toFloat())
|
||||||
|
postScale(scale, scale)
|
||||||
|
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f)
|
||||||
}
|
}
|
||||||
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
|
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
|
||||||
}
|
}
|
||||||
@@ -163,49 +316,176 @@ object WallpaperStore {
|
|||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
val metrics = context.resources.displayMetrics
|
val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
|
||||||
val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
|
||||||
var sample = 1
|
var sample = 1
|
||||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
|
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
|
||||||
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
|
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
|
||||||
inSampleSize = sample
|
|
||||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun thumbnailDataUrl(file: File): String {
|
private fun thumbnailDataUrl(context: Context, file: File): String {
|
||||||
|
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
|
||||||
|
thumbnailCache.get(cacheKey)?.let { return it }
|
||||||
|
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
|
||||||
|
if (cached.isFile) {
|
||||||
|
cached.setLastModified(System.currentTimeMillis())
|
||||||
|
val result = "data:image/jpeg;base64," + Base64.encodeToString(cached.readBytes(), Base64.NO_WRAP)
|
||||||
|
thumbnailCache.put(cacheKey, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
var sample = 1
|
var sample = 1
|
||||||
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
|
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
|
||||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
|
||||||
return ByteArrayOutputStream().use { out ->
|
val result = ByteArrayOutputStream().use { out ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
|
||||||
bitmap.recycle()
|
val bytes = out.toByteArray()
|
||||||
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
runCatching { cached.writeBytes(bytes); trimDirectory(thumbnailDirectory(context), THUMBNAIL_CACHE_MAX_BYTES, cached) }
|
||||||
|
"data:image/jpeg;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
thumbnailCache.put(cacheKey, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeThumbnail(file: File) {
|
||||||
|
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun renderedFile(context: Context, entry: Entry): File {
|
||||||
|
val metrics = context.resources.displayMetrics
|
||||||
|
val sourceVersion = when (entry) {
|
||||||
|
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||||
|
is Entry.Immich -> entry.assetId
|
||||||
|
}
|
||||||
|
val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}"
|
||||||
|
return File(renderedDirectory(context), "${digest(key)}.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sourceVersion(entry: Entry): String = when (entry) {
|
||||||
|
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
|
||||||
|
is Entry.Immich -> entry.assetId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validImage(file: File): Boolean {
|
||||||
|
if (!file.isFile || file.length() <= 0) return false
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimDirectory(directory: File, maxBytes: Long, protected: File) {
|
||||||
|
val files = directory.listFiles()?.filter { it.isFile }?.sortedBy { it.lastModified() } ?: return
|
||||||
|
var bytes = files.sumOf { it.length() }
|
||||||
|
for (file in files) {
|
||||||
|
if (bytes <= maxBytes) break
|
||||||
|
if (file != protected) {
|
||||||
|
val length = file.length()
|
||||||
|
if (file.delete()) bytes -= length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun applyNext(context: Context): Boolean {
|
fun applyNext(context: Context, automatic: Boolean = false): Boolean {
|
||||||
val images = files(context)
|
val items = entries(context)
|
||||||
if (images.isEmpty()) return false
|
if (items.isEmpty()) return false
|
||||||
val preferences = prefs(context)
|
val previous = currentIndex(context, items)
|
||||||
val previous = preferences.getInt(KEY_INDEX, -1)
|
if (shuffle(context) && items.size > 1) {
|
||||||
val index = if (shuffle(context) && images.size > 1) {
|
val availableIds = items.mapTo(mutableSetOf()) { it.id }
|
||||||
generateSequence { Random.nextInt(images.size) }.first { it != previous }
|
val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
|
||||||
} else (previous + 1).mod(images.size)
|
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
|
||||||
val bitmap = decodeForScreen(context, images[index]) ?: return false
|
seenIds.add(items[previous].id)
|
||||||
val rendered = renderForScreen(context, bitmap, crop(context, images[index]))
|
|
||||||
try {
|
var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled()
|
||||||
val manager = WallpaperManager.getInstance(context)
|
if (candidates.isEmpty()) {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
|
seenIds.clear()
|
||||||
else manager.setBitmap(rendered)
|
seenIds.add(items[previous].id)
|
||||||
preferences.edit().putInt(KEY_INDEX, index).apply()
|
candidates = items.indices.filter { it != previous }.shuffled()
|
||||||
return true
|
}
|
||||||
} finally {
|
return applyCandidates(context, items, candidates, automatic, seenIds)
|
||||||
rendered.recycle()
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
}
|
||||||
|
val candidates = (1..items.size).map { (previous + it).mod(items.size) }
|
||||||
|
return applyCandidates(context, items, candidates, automatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun apply(context: Context, id: String): Boolean {
|
||||||
|
val items = entries(context)
|
||||||
|
val index = items.indexOfFirst { it.id == id }
|
||||||
|
if (index < 0) return false
|
||||||
|
val seenIds = if (shuffle(context)) {
|
||||||
|
val availableIds = items.mapTo(mutableSetOf()) { it.id }
|
||||||
|
prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
|
||||||
|
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
|
||||||
|
} else null
|
||||||
|
return applyCandidates(context, items, listOf(index), automatic = false, seenIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyCandidates(
|
||||||
|
context: Context,
|
||||||
|
items: List<Entry>,
|
||||||
|
candidates: List<Int>,
|
||||||
|
automatic: Boolean,
|
||||||
|
shuffleSeenIds: Set<String>? = null,
|
||||||
|
): Boolean {
|
||||||
|
val unavailableServers = mutableSetOf<String>()
|
||||||
|
for (index in candidates) {
|
||||||
|
val entry = items[index]
|
||||||
|
val renderedFile = renderedFile(context, entry)
|
||||||
|
var rendered = if (validImage(renderedFile)) {
|
||||||
|
renderedFile.setLastModified(System.currentTimeMillis())
|
||||||
|
BitmapFactory.decodeFile(renderedFile.absolutePath)
|
||||||
|
} else null
|
||||||
|
if (rendered == null) {
|
||||||
|
renderedFile.delete()
|
||||||
|
if (prefs(context).getString(INVALID_PREFIX + entry.id, null) == sourceVersion(entry)) continue
|
||||||
|
val sourceFile = when (entry) {
|
||||||
|
is Entry.Local -> entry.file
|
||||||
|
is Entry.Immich -> {
|
||||||
|
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context, allowMobileData(context)))
|
||||||
|
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
|
||||||
|
if (source == null) {
|
||||||
|
if (allowNetwork) unavailableServers.add(entry.serverUrl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val bitmap = decodeForScreen(context, sourceFile)
|
||||||
|
if (bitmap == null) {
|
||||||
|
prefs(context).edit().putString(INVALID_PREFIX + entry.id, sourceVersion(entry)).apply()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val generated = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull()
|
||||||
|
bitmap.recycle()
|
||||||
|
if (generated == null) continue
|
||||||
|
runCatching {
|
||||||
|
renderedFile.outputStream().use { generated.compress(Bitmap.CompressFormat.JPEG, 92, it) }
|
||||||
|
trimDirectory(renderedDirectory(context), RENDER_CACHE_MAX_BYTES, renderedFile)
|
||||||
|
}
|
||||||
|
rendered = generated
|
||||||
|
}
|
||||||
|
val wallpaper = rendered ?: continue
|
||||||
|
try {
|
||||||
|
val manager = WallpaperManager.getInstance(context)
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
|
||||||
|
else manager.setBitmap(wallpaper)
|
||||||
|
val editor = prefs(context).edit()
|
||||||
|
.remove(INVALID_PREFIX + entry.id)
|
||||||
|
.putString(KEY_CURRENT_ID, entry.id)
|
||||||
|
.putInt(KEY_INDEX, index)
|
||||||
|
if (shuffleSeenIds != null) editor.putStringSet(KEY_SHUFFLE_SEEN_IDS, shuffleSeenIds + entry.id)
|
||||||
|
editor.apply()
|
||||||
|
return true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Try the next usable entry without changing the current selection.
|
||||||
|
} finally {
|
||||||
|
wallpaper.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
|
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
|
||||||
<string name="wallpaper_service_text">Das Motiv wechselt beim Aktivieren des Displays.</string>
|
<string name="wallpaper_service_text">Das nächste Motiv wird beim Ausschalten des Displays vorbereitet.</string>
|
||||||
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
|
<string name="wallpaper_channel_name">Bildwechsel bei Display-Aktivierung</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow está activo</string>
|
||||||
|
<string name="wallpaper_service_text">El fondo cambia cuando se enciende la pantalla.</string>
|
||||||
|
<string name="wallpaper_channel_name">Rotación al encender la pantalla</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow est actif</string>
|
||||||
|
<string name="wallpaper_service_text">Le fond d’écran change lorsque l’écran s’allume.</string>
|
||||||
|
<string name="wallpaper_channel_name">Rotation à l’allumage de l’écran</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow è attivo</string>
|
||||||
|
<string name="wallpaper_service_text">Lo sfondo cambia quando si accende lo schermo.</string>
|
||||||
|
<string name="wallpaper_channel_name">Rotazione all’accensione dello schermo</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow は実行中です</string>
|
||||||
|
<string name="wallpaper_service_text">画面が点灯すると壁紙が切り替わります。</string>
|
||||||
|
<string name="wallpaper_channel_name">画面点灯時の壁紙切り替え</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow 실행 중</string>
|
||||||
|
<string name="wallpaper_service_text">화면이 켜질 때 배경화면이 변경됩니다.</string>
|
||||||
|
<string name="wallpaper_channel_name">화면 켜짐 시 배경화면 변경</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow is actief</string>
|
||||||
|
<string name="wallpaper_service_text">De achtergrond wisselt wanneer het scherm aangaat.</string>
|
||||||
|
<string name="wallpaper_channel_name">Wisselen bij scherminschakeling</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow jest aktywny</string>
|
||||||
|
<string name="wallpaper_service_text">Tapeta zmienia się po włączeniu ekranu.</string>
|
||||||
|
<string name="wallpaper_channel_name">Zmiana po włączeniu ekranu</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow está ativo</string>
|
||||||
|
<string name="wallpaper_service_text">O fundo muda quando o ecrã é ligado.</string>
|
||||||
|
<string name="wallpaper_channel_name">Rotação ao ligar o ecrã</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="wallpaper_service_title">WallpaperFlow 正在运行</string>
|
||||||
|
<string name="wallpaper_service_text">屏幕亮起时会更换壁纸。</string>
|
||||||
|
<string name="wallpaper_channel_name">亮屏时更换壁纸</string>
|
||||||
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="wallpaper_service_title">WallpaperFlow is active</string>
|
<string name="wallpaper_service_title">WallpaperFlow is active</string>
|
||||||
<string name="wallpaper_service_text">The wallpaper changes whenever the screen wakes.</string>
|
<string name="wallpaper_service_text">The next wallpaper is prepared when the screen turns off.</string>
|
||||||
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
|
<string name="wallpaper_channel_name">Screen-on wallpaper rotation</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ const COMMANDS: &[&str] = &[
|
|||||||
"get_gallery",
|
"get_gallery",
|
||||||
"select_images",
|
"select_images",
|
||||||
"delete_image",
|
"delete_image",
|
||||||
|
"get_image_ids",
|
||||||
|
"delete_images",
|
||||||
"set_image_crop",
|
"set_image_crop",
|
||||||
"set_setting",
|
"set_setting",
|
||||||
|
"set_interval",
|
||||||
"next_wallpaper",
|
"next_wallpaper",
|
||||||
|
"apply_wallpaper",
|
||||||
|
"get_immich_connection",
|
||||||
|
"connect_immich",
|
||||||
|
"disconnect_immich",
|
||||||
|
"get_immich_albums",
|
||||||
|
"get_immich_assets",
|
||||||
|
"import_immich_assets",
|
||||||
|
"get_immich_import_progress",
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-apply-wallpaper"
|
||||||
|
description = "Enables the apply_wallpaper command without any pre-configured scope."
|
||||||
|
commands.allow = ["apply_wallpaper"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-apply-wallpaper"
|
||||||
|
description = "Denies the apply_wallpaper command without any pre-configured scope."
|
||||||
|
commands.deny = ["apply_wallpaper"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-connect-immich"
|
||||||
|
description = "Enables the connect_immich command without any pre-configured scope."
|
||||||
|
commands.allow = ["connect_immich"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-connect-immich"
|
||||||
|
description = "Denies the connect_immich command without any pre-configured scope."
|
||||||
|
commands.deny = ["connect_immich"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-delete-images"
|
||||||
|
description = "Enables the delete_images command without any pre-configured scope."
|
||||||
|
commands.allow = ["delete_images"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-delete-images"
|
||||||
|
description = "Denies the delete_images command without any pre-configured scope."
|
||||||
|
commands.deny = ["delete_images"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-disconnect-immich"
|
||||||
|
description = "Enables the disconnect_immich command without any pre-configured scope."
|
||||||
|
commands.allow = ["disconnect_immich"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-disconnect-immich"
|
||||||
|
description = "Denies the disconnect_immich command without any pre-configured scope."
|
||||||
|
commands.deny = ["disconnect_immich"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-get-image-ids"
|
||||||
|
description = "Enables the get_image_ids command without any pre-configured scope."
|
||||||
|
commands.allow = ["get_image_ids"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-get-image-ids"
|
||||||
|
description = "Denies the get_image_ids command without any pre-configured scope."
|
||||||
|
commands.deny = ["get_image_ids"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-get-immich-albums"
|
||||||
|
description = "Enables the get_immich_albums command without any pre-configured scope."
|
||||||
|
commands.allow = ["get_immich_albums"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-get-immich-albums"
|
||||||
|
description = "Denies the get_immich_albums command without any pre-configured scope."
|
||||||
|
commands.deny = ["get_immich_albums"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-get-immich-assets"
|
||||||
|
description = "Enables the get_immich_assets command without any pre-configured scope."
|
||||||
|
commands.allow = ["get_immich_assets"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-get-immich-assets"
|
||||||
|
description = "Denies the get_immich_assets command without any pre-configured scope."
|
||||||
|
commands.deny = ["get_immich_assets"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-get-immich-connection"
|
||||||
|
description = "Enables the get_immich_connection command without any pre-configured scope."
|
||||||
|
commands.allow = ["get_immich_connection"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-get-immich-connection"
|
||||||
|
description = "Denies the get_immich_connection command without any pre-configured scope."
|
||||||
|
commands.deny = ["get_immich_connection"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-get-immich-import-progress"
|
||||||
|
description = "Enables the get_immich_import_progress command without any pre-configured scope."
|
||||||
|
commands.allow = ["get_immich_import_progress"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-get-immich-import-progress"
|
||||||
|
description = "Denies the get_immich_import_progress command without any pre-configured scope."
|
||||||
|
commands.deny = ["get_immich_import_progress"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-import-immich-assets"
|
||||||
|
description = "Enables the import_immich_assets command without any pre-configured scope."
|
||||||
|
commands.allow = ["import_immich_assets"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-import-immich-assets"
|
||||||
|
description = "Denies the import_immich_assets command without any pre-configured scope."
|
||||||
|
commands.deny = ["import_immich_assets"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-set-interval"
|
||||||
|
description = "Enables the set_interval command without any pre-configured scope."
|
||||||
|
commands.allow = ["set_interval"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-set-interval"
|
||||||
|
description = "Denies the set_interval command without any pre-configured scope."
|
||||||
|
commands.deny = ["set_interval"]
|
||||||
@@ -8,9 +8,20 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
|
|||||||
- `allow-get-gallery`
|
- `allow-get-gallery`
|
||||||
- `allow-select-images`
|
- `allow-select-images`
|
||||||
- `allow-delete-image`
|
- `allow-delete-image`
|
||||||
|
- `allow-get-image-ids`
|
||||||
|
- `allow-delete-images`
|
||||||
- `allow-set-image-crop`
|
- `allow-set-image-crop`
|
||||||
- `allow-set-setting`
|
- `allow-set-setting`
|
||||||
|
- `allow-set-interval`
|
||||||
- `allow-next-wallpaper`
|
- `allow-next-wallpaper`
|
||||||
|
- `allow-apply-wallpaper`
|
||||||
|
- `allow-get-immich-connection`
|
||||||
|
- `allow-connect-immich`
|
||||||
|
- `allow-disconnect-immich`
|
||||||
|
- `allow-get-immich-albums`
|
||||||
|
- `allow-get-immich-assets`
|
||||||
|
- `allow-import-immich-assets`
|
||||||
|
- `allow-get-immich-import-progress`
|
||||||
|
|
||||||
## Permission Table
|
## Permission Table
|
||||||
|
|
||||||
@@ -21,6 +32,58 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-apply-wallpaper`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the apply_wallpaper command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-apply-wallpaper`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the apply_wallpaper command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-connect-immich`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the connect_immich command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-connect-immich`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the connect_immich command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
@@ -50,6 +113,58 @@ Denies the delete_image command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-delete-images`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the delete_images command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-delete-images`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the delete_images command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-disconnect-immich`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the disconnect_immich command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-disconnect-immich`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the disconnect_immich command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`wallpaper:allow-get-gallery`
|
`wallpaper:allow-get-gallery`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -76,6 +191,136 @@ Denies the get_gallery command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-get-image-ids`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the get_image_ids command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-get-image-ids`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the get_image_ids command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-get-immich-albums`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the get_immich_albums command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-get-immich-albums`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the get_immich_albums command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-get-immich-assets`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the get_immich_assets command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-get-immich-assets`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the get_immich_assets command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-get-immich-connection`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the get_immich_connection command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-get-immich-connection`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the get_immich_connection command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-get-immich-import-progress`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the get_immich_import_progress command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-get-immich-import-progress`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the get_immich_import_progress command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`wallpaper:allow-get-state`
|
`wallpaper:allow-get-state`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -102,6 +347,32 @@ Denies the get_state command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-import-immich-assets`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the import_immich_assets command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-import-immich-assets`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the import_immich_assets command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`wallpaper:allow-next-wallpaper`
|
`wallpaper:allow-next-wallpaper`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -180,6 +451,32 @@ Denies the set_image_crop command without any pre-configured scope.
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:allow-set-interval`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the set_interval command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`wallpaper:deny-set-interval`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the set_interval command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
`wallpaper:allow-set-setting`
|
`wallpaper:allow-set-setting`
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[default]
|
[default]
|
||||||
description = "Allow the LockScreenWallpaper app to manage its wallpaper collection"
|
description = "Allow the LockScreenWallpaper app to manage its wallpaper collection"
|
||||||
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-set-image-crop", "allow-set-setting", "allow-next-wallpaper"]
|
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-get-image-ids", "allow-delete-images", "allow-set-image-crop", "allow-set-setting", "allow-set-interval", "allow-next-wallpaper", "allow-apply-wallpaper", "allow-get-immich-connection", "allow-connect-immich", "allow-disconnect-immich", "allow-get-immich-albums", "allow-get-immich-assets", "allow-import-immich-assets", "allow-get-immich-import-progress"]
|
||||||
|
|||||||
@@ -294,6 +294,30 @@
|
|||||||
"PermissionKind": {
|
"PermissionKind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "Enables the apply_wallpaper command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-apply-wallpaper",
|
||||||
|
"markdownDescription": "Enables the apply_wallpaper command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the apply_wallpaper command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-apply-wallpaper",
|
||||||
|
"markdownDescription": "Denies the apply_wallpaper command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the connect_immich command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-connect-immich",
|
||||||
|
"markdownDescription": "Enables the connect_immich command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the connect_immich command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-connect-immich",
|
||||||
|
"markdownDescription": "Denies the connect_immich command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the delete_image command without any pre-configured scope.",
|
"description": "Enables the delete_image command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -306,6 +330,30 @@
|
|||||||
"const": "deny-delete-image",
|
"const": "deny-delete-image",
|
||||||
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
|
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_images command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-delete-images",
|
||||||
|
"markdownDescription": "Enables the delete_images command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_images command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-delete-images",
|
||||||
|
"markdownDescription": "Denies the delete_images command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the disconnect_immich command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-disconnect-immich",
|
||||||
|
"markdownDescription": "Enables the disconnect_immich command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the disconnect_immich command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-disconnect-immich",
|
||||||
|
"markdownDescription": "Denies the disconnect_immich command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the get_gallery command without any pre-configured scope.",
|
"description": "Enables the get_gallery command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -318,6 +366,66 @@
|
|||||||
"const": "deny-get-gallery",
|
"const": "deny-get-gallery",
|
||||||
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
|
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_image_ids command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-get-image-ids",
|
||||||
|
"markdownDescription": "Enables the get_image_ids command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_image_ids command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-get-image-ids",
|
||||||
|
"markdownDescription": "Denies the get_image_ids command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_immich_albums command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-get-immich-albums",
|
||||||
|
"markdownDescription": "Enables the get_immich_albums command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_immich_albums command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-get-immich-albums",
|
||||||
|
"markdownDescription": "Denies the get_immich_albums command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_immich_assets command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-get-immich-assets",
|
||||||
|
"markdownDescription": "Enables the get_immich_assets command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_immich_assets command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-get-immich-assets",
|
||||||
|
"markdownDescription": "Denies the get_immich_assets command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_immich_connection command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-get-immich-connection",
|
||||||
|
"markdownDescription": "Enables the get_immich_connection command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_immich_connection command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-get-immich-connection",
|
||||||
|
"markdownDescription": "Denies the get_immich_connection command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_immich_import_progress command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-get-immich-import-progress",
|
||||||
|
"markdownDescription": "Enables the get_immich_import_progress command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_immich_import_progress command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-get-immich-import-progress",
|
||||||
|
"markdownDescription": "Denies the get_immich_import_progress command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the get_state command without any pre-configured scope.",
|
"description": "Enables the get_state command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -330,6 +438,18 @@
|
|||||||
"const": "deny-get-state",
|
"const": "deny-get-state",
|
||||||
"markdownDescription": "Denies the get_state command without any pre-configured scope."
|
"markdownDescription": "Denies the get_state command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the import_immich_assets command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-import-immich-assets",
|
||||||
|
"markdownDescription": "Enables the import_immich_assets command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the import_immich_assets command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-import-immich-assets",
|
||||||
|
"markdownDescription": "Denies the import_immich_assets command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the next_wallpaper command without any pre-configured scope.",
|
"description": "Enables the next_wallpaper command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -366,6 +486,18 @@
|
|||||||
"const": "deny-set-image-crop",
|
"const": "deny-set-image-crop",
|
||||||
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the set_interval command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-set-interval",
|
||||||
|
"markdownDescription": "Enables the set_interval command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the set_interval command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-set-interval",
|
||||||
|
"markdownDescription": "Denies the set_interval command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the set_setting command without any pre-configured scope.",
|
"description": "Enables the set_setting command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -379,10 +511,10 @@
|
|||||||
"markdownDescription": "Denies the set_setting command without any pre-configured scope."
|
"markdownDescription": "Denies the set_setting command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
|
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-apply-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "default",
|
"const": "default",
|
||||||
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
|
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-get-image-ids`\n- `allow-delete-images`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-set-interval`\n- `allow-next-wallpaper`\n- `allow-apply-wallpaper`\n- `allow-get-immich-connection`\n- `allow-connect-immich`\n- `allow-disconnect-immich`\n- `allow-get-immich-albums`\n- `allow-get-immich-assets`\n- `allow-import-immich-assets`\n- `allow-get-immich-import-progress`"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ pub(crate) async fn delete_image<R: Runtime>(
|
|||||||
app.wallpaper().delete_image(DeleteImageRequest { id })
|
app.wallpaper().delete_image(DeleteImageRequest { id })
|
||||||
}
|
}
|
||||||
#[command]
|
#[command]
|
||||||
|
pub(crate) async fn get_image_ids<R: Runtime>(app: AppHandle<R>) -> Result<Vec<String>> {
|
||||||
|
app.wallpaper().get_image_ids()
|
||||||
|
}
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn delete_images<R: Runtime>(app: AppHandle<R>, ids: Vec<String>) -> Result<WallpaperState> {
|
||||||
|
app.wallpaper().delete_images(DeleteImagesRequest { ids })
|
||||||
|
}
|
||||||
|
#[command]
|
||||||
pub(crate) async fn set_image_crop<R: Runtime>(
|
pub(crate) async fn set_image_crop<R: Runtime>(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<R>,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -36,6 +44,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
|
|||||||
zoom: f64,
|
zoom: f64,
|
||||||
position_x: f64,
|
position_x: f64,
|
||||||
position_y: f64,
|
position_y: f64,
|
||||||
|
rotation: i32,
|
||||||
) -> Result<GalleryImage> {
|
) -> Result<GalleryImage> {
|
||||||
app.wallpaper().set_image_crop(ImageCropRequest {
|
app.wallpaper().set_image_crop(ImageCropRequest {
|
||||||
id,
|
id,
|
||||||
@@ -43,6 +52,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
|
|||||||
zoom,
|
zoom,
|
||||||
position_x,
|
position_x,
|
||||||
position_y,
|
position_y,
|
||||||
|
rotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
#[command]
|
#[command]
|
||||||
@@ -54,6 +64,61 @@ pub(crate) async fn set_setting<R: Runtime>(
|
|||||||
app.wallpaper().set_setting(SettingRequest { name, value })
|
app.wallpaper().set_setting(SettingRequest { name, value })
|
||||||
}
|
}
|
||||||
#[command]
|
#[command]
|
||||||
|
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: i32) -> Result<WallpaperState> {
|
||||||
|
app.wallpaper().set_interval(IntervalRequest { minutes })
|
||||||
|
}
|
||||||
|
#[command]
|
||||||
pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
|
pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
|
||||||
app.wallpaper().next_wallpaper()
|
app.wallpaper().next_wallpaper()
|
||||||
}
|
}
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn apply_wallpaper<R: Runtime>(app: AppHandle<R>, id: String) -> Result<WallpaperState> {
|
||||||
|
app.wallpaper().apply_wallpaper(ApplyWallpaperRequest { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn get_immich_connection<R: Runtime>(app: AppHandle<R>) -> Result<ImmichConnection> {
|
||||||
|
app.wallpaper().get_immich_connection()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn connect_immich<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
server_url: String,
|
||||||
|
api_key: String,
|
||||||
|
) -> Result<ImmichConnection> {
|
||||||
|
app.wallpaper().connect_immich(ImmichConnectRequest { server_url, api_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn disconnect_immich<R: Runtime>(app: AppHandle<R>) -> Result<ImmichConnection> {
|
||||||
|
app.wallpaper().disconnect_immich()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn get_immich_albums<R: Runtime>(app: AppHandle<R>) -> Result<ImmichAlbumsResponse> {
|
||||||
|
app.wallpaper().get_immich_albums()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn get_immich_assets<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
album_id: Option<String>,
|
||||||
|
page: usize,
|
||||||
|
size: usize,
|
||||||
|
) -> Result<ImmichAssetsPage> {
|
||||||
|
app.wallpaper().get_immich_assets(ImmichAssetsRequest { album_id, page, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn import_immich_assets<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
ids: Vec<String>,
|
||||||
|
) -> Result<WallpaperState> {
|
||||||
|
app.wallpaper().import_immich_assets(ImmichImportRequest { ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub(crate) async fn get_immich_import_progress<R: Runtime>(app: AppHandle<R>) -> Result<ImmichImportProgress> {
|
||||||
|
app.wallpaper().get_immich_import_progress()
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
WallpaperState {
|
WallpaperState {
|
||||||
image_count: 3,
|
image_count: 3,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
interval_minutes: 30,
|
||||||
shuffle: true,
|
shuffle: true,
|
||||||
lock_screen_only: true,
|
lock_screen_only: true,
|
||||||
|
allow_mobile_data: false,
|
||||||
|
prefetch_immich: true,
|
||||||
current_index: 0,
|
current_index: 0,
|
||||||
|
current_id: Some("demo-0".into()),
|
||||||
|
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
|
||||||
image_urls: vec![
|
image_urls: vec![
|
||||||
"/wallpapers/alpine.png".into(),
|
"/wallpapers/alpine.png".into(),
|
||||||
"/wallpapers/waterfall.png".into(),
|
"/wallpapers/waterfall.png".into(),
|
||||||
@@ -46,6 +51,7 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
crop_zoom: 1.0,
|
crop_zoom: 1.0,
|
||||||
crop_position_x: 0.5,
|
crop_position_x: 0.5,
|
||||||
crop_position_y: 0.5,
|
crop_position_y: 0.5,
|
||||||
|
crop_rotation: 0,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(GalleryPage { total: 3, items })
|
Ok(GalleryPage { total: 3, items })
|
||||||
@@ -56,6 +62,12 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
pub fn delete_image(&self, _payload: DeleteImageRequest) -> crate::Result<WallpaperState> {
|
pub fn delete_image(&self, _payload: DeleteImageRequest) -> crate::Result<WallpaperState> {
|
||||||
Ok(Self::demo())
|
Ok(Self::demo())
|
||||||
}
|
}
|
||||||
|
pub fn get_image_ids(&self) -> crate::Result<Vec<String>> {
|
||||||
|
Ok((0..Self::demo().image_count).map(|index| format!("demo-{index}")).collect())
|
||||||
|
}
|
||||||
|
pub fn delete_images(&self, _payload: DeleteImagesRequest) -> crate::Result<WallpaperState> {
|
||||||
|
Ok(Self::demo())
|
||||||
|
}
|
||||||
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
|
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
|
||||||
let url = Self::demo()
|
let url = Self::demo()
|
||||||
.image_urls
|
.image_urls
|
||||||
@@ -70,6 +82,7 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
crop_zoom: payload.zoom,
|
crop_zoom: payload.zoom,
|
||||||
crop_position_x: payload.position_x,
|
crop_position_x: payload.position_x,
|
||||||
crop_position_y: payload.position_y,
|
crop_position_y: payload.position_y,
|
||||||
|
crop_rotation: payload.rotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
|
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
|
||||||
@@ -78,13 +91,76 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
"enabled" => state.enabled = payload.value,
|
"enabled" => state.enabled = payload.value,
|
||||||
"shuffle" => state.shuffle = payload.value,
|
"shuffle" => state.shuffle = payload.value,
|
||||||
"lockScreenOnly" => state.lock_screen_only = payload.value,
|
"lockScreenOnly" => state.lock_screen_only = payload.value,
|
||||||
|
"allowMobileData" => state.allow_mobile_data = payload.value,
|
||||||
|
"prefetchImmich" => state.prefetch_immich = payload.value,
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> {
|
||||||
|
let mut state = Self::demo();
|
||||||
|
state.interval_minutes = payload.minutes;
|
||||||
|
state.enabled = payload.minutes != 0;
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
||||||
let mut state = Self::demo();
|
let mut state = Self::demo();
|
||||||
state.current_index = 1;
|
state.current_index = 1;
|
||||||
|
state.current_id = Some("demo-1".into());
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
pub fn apply_wallpaper(&self, payload: ApplyWallpaperRequest) -> crate::Result<WallpaperState> {
|
||||||
|
let mut state = Self::demo();
|
||||||
|
if let Some(index) = state.image_ids.iter().position(|id| id == &payload.id) {
|
||||||
|
state.current_index = index;
|
||||||
|
state.current_id = Some(payload.id);
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
|
||||||
|
Ok(ImmichConnection::default())
|
||||||
|
}
|
||||||
|
pub fn connect_immich(&self, payload: ImmichConnectRequest) -> crate::Result<ImmichConnection> {
|
||||||
|
Ok(ImmichConnection {
|
||||||
|
configured: true,
|
||||||
|
server_url: payload.server_url,
|
||||||
|
user_name: "Demo User".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn disconnect_immich(&self) -> crate::Result<ImmichConnection> {
|
||||||
|
Ok(ImmichConnection::default())
|
||||||
|
}
|
||||||
|
pub fn get_immich_albums(&self) -> crate::Result<ImmichAlbumsResponse> {
|
||||||
|
Ok(ImmichAlbumsResponse {
|
||||||
|
albums: vec![ImmichAlbum {
|
||||||
|
id: "demo-album".into(),
|
||||||
|
name: "Nature".into(),
|
||||||
|
asset_count: 3,
|
||||||
|
thumbnail_url: "/wallpapers/alpine.png".into(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn get_immich_assets(&self, payload: ImmichAssetsRequest) -> crate::Result<ImmichAssetsPage> {
|
||||||
|
let urls = Self::demo().image_urls;
|
||||||
|
Ok(ImmichAssetsPage {
|
||||||
|
items: urls
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, thumbnail_url)| ImmichAsset {
|
||||||
|
id: format!("immich-demo-{index}"),
|
||||||
|
file_name: format!("wallpaper-{}.jpg", index + 1),
|
||||||
|
thumbnail_url,
|
||||||
|
taken_at: "2026-08-21T12:00:00Z".into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
page: payload.page,
|
||||||
|
has_more: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn import_immich_assets(&self, _payload: ImmichImportRequest) -> crate::Result<WallpaperState> {
|
||||||
|
Ok(Self::demo())
|
||||||
|
}
|
||||||
|
pub fn get_immich_import_progress(&self) -> crate::Result<ImmichImportProgress> {
|
||||||
|
Ok(ImmichImportProgress::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
commands::get_gallery,
|
commands::get_gallery,
|
||||||
commands::select_images,
|
commands::select_images,
|
||||||
commands::delete_image,
|
commands::delete_image,
|
||||||
|
commands::get_image_ids,
|
||||||
|
commands::delete_images,
|
||||||
commands::set_image_crop,
|
commands::set_image_crop,
|
||||||
commands::set_setting,
|
commands::set_setting,
|
||||||
commands::next_wallpaper
|
commands::set_interval,
|
||||||
|
commands::next_wallpaper,
|
||||||
|
commands::apply_wallpaper,
|
||||||
|
commands::get_immich_connection,
|
||||||
|
commands::connect_immich,
|
||||||
|
commands::disconnect_immich,
|
||||||
|
commands::get_immich_albums,
|
||||||
|
commands::get_immich_assets,
|
||||||
|
commands::import_immich_assets,
|
||||||
|
commands::get_immich_import_progress
|
||||||
])
|
])
|
||||||
.setup(|app, api| {
|
.setup(|app, api| {
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
.run_mobile_plugin("deleteImage", payload)
|
.run_mobile_plugin("deleteImage", payload)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
pub fn get_image_ids(&self) -> crate::Result<Vec<String>> {
|
||||||
|
let response: ImageIdsResponse = self.0.run_mobile_plugin("getImageIds", ())?;
|
||||||
|
Ok(response.ids)
|
||||||
|
}
|
||||||
|
pub fn delete_images(&self, payload: DeleteImagesRequest) -> crate::Result<WallpaperState> {
|
||||||
|
self.0.run_mobile_plugin("deleteImages", payload).map_err(Into::into)
|
||||||
|
}
|
||||||
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
|
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
|
||||||
self.0
|
self.0
|
||||||
.run_mobile_plugin("setImageCrop", payload)
|
.run_mobile_plugin("setImageCrop", payload)
|
||||||
@@ -53,9 +60,40 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
.run_mobile_plugin("setSetting", payload)
|
.run_mobile_plugin("setSetting", payload)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("setInterval", payload)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
|
||||||
self.0
|
self.0
|
||||||
.run_mobile_plugin("nextWallpaper", ())
|
.run_mobile_plugin("nextWallpaper", ())
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
pub fn apply_wallpaper(&self, payload: ApplyWallpaperRequest) -> crate::Result<WallpaperState> {
|
||||||
|
self.0
|
||||||
|
.run_mobile_plugin("applyWallpaper", payload)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn get_immich_connection(&self) -> crate::Result<ImmichConnection> {
|
||||||
|
self.0.run_mobile_plugin("getImmichConnection", ()).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn connect_immich(&self, payload: ImmichConnectRequest) -> crate::Result<ImmichConnection> {
|
||||||
|
self.0.run_mobile_plugin("connectImmich", payload).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn disconnect_immich(&self) -> crate::Result<ImmichConnection> {
|
||||||
|
self.0.run_mobile_plugin("disconnectImmich", ()).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn get_immich_albums(&self) -> crate::Result<ImmichAlbumsResponse> {
|
||||||
|
self.0.run_mobile_plugin("getImmichAlbums", ()).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn get_immich_assets(&self, payload: ImmichAssetsRequest) -> crate::Result<ImmichAssetsPage> {
|
||||||
|
self.0.run_mobile_plugin("getImmichAssets", payload).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn import_immich_assets(&self, payload: ImmichImportRequest) -> crate::Result<WallpaperState> {
|
||||||
|
self.0.run_mobile_plugin("importImmichAssets", payload).map_err(Into::into)
|
||||||
|
}
|
||||||
|
pub fn get_immich_import_progress(&self) -> crate::Result<ImmichImportProgress> {
|
||||||
|
self.0.run_mobile_plugin("getImmichImportProgress", ()).map_err(Into::into)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct WallpaperState {
|
pub struct WallpaperState {
|
||||||
pub image_count: usize,
|
pub image_count: usize,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub interval_minutes: i32,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub lock_screen_only: bool,
|
pub lock_screen_only: bool,
|
||||||
|
pub allow_mobile_data: bool,
|
||||||
|
pub prefetch_immich: bool,
|
||||||
pub current_index: usize,
|
pub current_index: usize,
|
||||||
|
pub current_id: Option<String>,
|
||||||
|
pub image_ids: Vec<String>,
|
||||||
pub image_urls: Vec<String>,
|
pub image_urls: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +23,12 @@ pub struct SettingRequest {
|
|||||||
pub value: bool,
|
pub value: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IntervalRequest {
|
||||||
|
pub minutes: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GalleryRequest {
|
pub struct GalleryRequest {
|
||||||
@@ -31,6 +42,24 @@ pub struct DeleteImageRequest {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApplyWallpaperRequest {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteImagesRequest {
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImageIdsResponse {
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ImageCropRequest {
|
pub struct ImageCropRequest {
|
||||||
@@ -39,6 +68,7 @@ pub struct ImageCropRequest {
|
|||||||
pub zoom: f64,
|
pub zoom: f64,
|
||||||
pub position_x: f64,
|
pub position_x: f64,
|
||||||
pub position_y: f64,
|
pub position_y: f64,
|
||||||
|
pub rotation: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -51,6 +81,7 @@ pub struct GalleryImage {
|
|||||||
pub crop_zoom: f64,
|
pub crop_zoom: f64,
|
||||||
pub crop_position_x: f64,
|
pub crop_position_x: f64,
|
||||||
pub crop_position_y: f64,
|
pub crop_position_y: f64,
|
||||||
|
pub crop_rotation: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -59,3 +90,74 @@ pub struct GalleryPage {
|
|||||||
pub total: usize,
|
pub total: usize,
|
||||||
pub items: Vec<GalleryImage>,
|
pub items: Vec<GalleryImage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichConnectRequest {
|
||||||
|
pub server_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichConnection {
|
||||||
|
pub configured: bool,
|
||||||
|
pub server_url: String,
|
||||||
|
pub user_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichAlbum {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub asset_count: usize,
|
||||||
|
pub thumbnail_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichAlbumsResponse {
|
||||||
|
pub albums: Vec<ImmichAlbum>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichAssetsRequest {
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
pub page: usize,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichAsset {
|
||||||
|
pub id: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub thumbnail_url: String,
|
||||||
|
pub taken_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichAssetsPage {
|
||||||
|
pub items: Vec<ImmichAsset>,
|
||||||
|
pub page: usize,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichImportRequest {
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImmichImportProgress {
|
||||||
|
pub active: bool,
|
||||||
|
pub completed: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub bytes_downloaded: u64,
|
||||||
|
pub bytes_total: u64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
<rect width="512" height="512" rx="116" fill="#145d32"/>
|
<rect width="512" height="512" rx="116" fill="#edf4e9"/>
|
||||||
<path d="M142 128h228a42 42 0 0 1 42 42v210a42 42 0 0 1-42 42H142a42 42 0 0 1-42-42V170a42 42 0 0 1 42-42Z" fill="#f8faf7" opacity=".22"/>
|
<circle cx="256" cy="256" r="177" fill="#0f5b32"/>
|
||||||
<path d="M172 92h190a38 38 0 0 1 38 38v214a38 38 0 0 1-38 38H172a38 38 0 0 1-38-38V130a38 38 0 0 1 38-38Z" fill="#f8faf7"/>
|
<path d="M143 191c24-45 66-77 116-86" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
<circle cx="321" cy="170" r="31" fill="#a9cda7"/>
|
<path d="m238 93 31 10-25 20Z" fill="#91bf93"/>
|
||||||
<path d="m157 319 72-91 53 62 37-42 65 79v17a17 17 0 0 1-17 17H168a17 17 0 0 1-17-17v-15Z" fill="#145d32"/>
|
<path d="m146 211 55 128 55-103 55 103 55-128" fill="none" stroke="#f8faf4" stroke-width="34" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M366 112a122 122 0 0 1 39 61" fill="none" stroke="#a9cda7" stroke-width="18" stroke-linecap="round"/>
|
<circle cx="366" cy="177" r="24" fill="#f2c85b"/>
|
||||||
|
<path d="M369 365c-25 28-59 47-97 52" fill="none" stroke="#91bf93" stroke-width="19" stroke-linecap="round"/>
|
||||||
|
<path d="m291 426-30-8 23-21Z" fill="#91bf93"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 671 B After Width: | Height: | Size: 703 B |
@@ -0,0 +1,9 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.95.0"
|
||||||
|
profile = "minimal"
|
||||||
|
targets = [
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi",
|
||||||
|
"i686-linux-android",
|
||||||
|
"x86_64-linux-android",
|
||||||
|
]
|
||||||
@@ -3,6 +3,7 @@ name = "lockscreenwallpaper"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Unbegrenzter Sperrbildschirm-Wechsel für Android"
|
description = "Unbegrenzter Sperrbildschirm-Wechsel für Android"
|
||||||
authors = ["LockScreenWallpaper"]
|
authors = ["LockScreenWallpaper"]
|
||||||
|
license = "GPL-3.0-only"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ android {
|
|||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
namespace = "de.wechselbild.app"
|
namespace = "de.wechselbild.app"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
// Immich is frequently hosted on a private LAN without TLS. The app only
|
||||||
|
// connects to the server URL explicitly entered by the user.
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
applicationId = "de.wechselbild.app"
|
applicationId = "de.wechselbild.app"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
@@ -68,4 +70,4 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
apply(from = "tauri.build.gradle.kts")
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |