diff --git a/.gitea/workflows/android-release.yml b/.gitea/workflows/android-release.yml new file mode 100644 index 0000000..b43d269 --- /dev/null +++ b/.gitea/workflows/android-release.yml @@ -0,0 +1,128 @@ +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 + 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 }} + + 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: Check release tag and app version + if: gitea.ref_type == 'tag' + run: | + APP_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")" + if [ "${GITHUB_REF_NAME}" != "v${APP_VERSION}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match app version v${APP_VERSION}." >&2 + exit 1 + fi + + - name: Validate signing secrets + run: | + for SECRET_NAME in \ + ANDROID_KEYSTORE_BASE64 \ + ANDROID_KEYSTORE_PASSWORD \ + ANDROID_KEY_ALIAS \ + ANDROID_KEY_PASSWORD + 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: 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: Upload release APK + uses: actions/upload-artifact@v3 + with: + name: WallpaperFlow-${{ gitea.ref_name }}-android + path: | + release/WallpaperFlow-release.apk + release/WallpaperFlow-release.apk.sha256 + if-no-files-found: error diff --git a/README.md b/README.md index 6d101c4..7110cfa 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,36 @@ npm run tauri android dev 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 `v0.1.0` +eine signierte Universal-APK. Der Tag muss der Version in +`src-tauri/tauri.conf.json` mit vorangestelltem `v` entsprechen. Der Workflow +kann außerdem manuell über die Actions-Oberfläche gestartet werden. + +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 stehen `WallpaperFlow-release.apk` und die zugehörige +SHA-256-Prüfsumme als Artefakt des Workflows bereit. 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 - Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist. diff --git a/plugins/android/src/main/java/WallpaperStore.kt b/plugins/android/src/main/java/WallpaperStore.kt index 7357d8a..1b20ebe 100644 --- a/plugins/android/src/main/java/WallpaperStore.kt +++ b/plugins/android/src/main/java/WallpaperStore.kt @@ -22,6 +22,7 @@ object WallpaperStore { 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 INVALID_PREFIX = "invalid_" private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024 @@ -107,7 +108,9 @@ object WallpaperStore { fun set(context: Context, name: String, value: Boolean) { require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" } - prefs(context).edit().putBoolean(name, value).apply() + 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) @@ -390,9 +393,21 @@ object WallpaperStore { val items = entries(context) if (items.isEmpty()) return false val previous = currentIndex(context, items) - val candidates = if (shuffle(context) && items.size > 1) { - items.indices.filter { it != previous }.shuffled() + previous - } else (1..items.size).map { (previous + it).mod(items.size) } + if (shuffle(context) && items.size > 1) { + val availableIds = items.mapTo(mutableSetOf()) { it.id } + val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet()) + .orEmpty().filterTo(mutableSetOf()) { it in availableIds } + seenIds.add(items[previous].id) + + var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled() + if (candidates.isEmpty()) { + seenIds.clear() + seenIds.add(items[previous].id) + candidates = items.indices.filter { it != previous }.shuffled() + } + return applyCandidates(context, items, candidates, automatic, seenIds) + } + val candidates = (1..items.size).map { (previous + it).mod(items.size) } return applyCandidates(context, items, candidates, automatic) } @@ -401,10 +416,21 @@ object WallpaperStore { val items = entries(context) val index = items.indexOfFirst { it.id == id } if (index < 0) return false - return applyCandidates(context, items, listOf(index), automatic = 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, candidates: List, automatic: Boolean): Boolean { + private fun applyCandidates( + context: Context, + items: List, + candidates: List, + automatic: Boolean, + shuffleSeenIds: Set? = null, + ): Boolean { val unavailableServers = mutableSetOf() for (index in candidates) { val entry = items[index] @@ -447,7 +473,12 @@ object WallpaperStore { 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) - prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply() + 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. diff --git a/src/App.tsx b/src/App.tsx index 0c273ca..1b681c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { initialLanguage, languageNames, languages, translations, type Language import { immichTranslations } from "./immich-i18n"; const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] }; +type Tab = "home" | "settings" | "gallery" | "editor" | "immich"; function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) { return ; @@ -24,7 +25,7 @@ function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }: export default function App() { const [state, setState] = useState(initial); - const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home"); + const [tab, setTab] = useState("home"); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(""); const [language, setLanguage] = useState(initialLanguage); @@ -46,6 +47,8 @@ export default function App() { const [immichImportProgress, setImmichImportProgress] = useState(null); const phonePreviewRef = useRef(null); const phoneImageRef = useRef(null); + const galleryLoadMoreRef = useRef(null); + const galleryLoadingRef = useRef(false); const previewPointers = useRef(new Map()); const previewGesture = useRef(null); const galleryScrollPosition = useRef(0); @@ -62,6 +65,12 @@ export default function App() { window.removeEventListener("focus", refresh); }; }, []); + useEffect(() => { + window.history.replaceState({ ...window.history.state, wallpaperFlowTab: "home" }, ""); + const onPopState = (event: PopStateEvent) => setTab((event.state?.wallpaperFlowTab as Tab | undefined) ?? "home"); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); useEffect(() => { getImmichConnection().then(connection => { setImmichConnection(connection); @@ -90,6 +99,15 @@ export default function App() { const settled = window.setTimeout(() => window.scrollTo(0, top), 120); return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); }; }, [tab]); + useEffect(() => { + const target = galleryLoadMoreRef.current; + if (tab !== "gallery" || !target || galleryLoading || gallery.length >= galleryTotal) return; + const observer = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) void loadGallery(gallery.length, true); + }, { rootMargin: "300px" }); + observer.observe(target); + return () => observer.disconnect(); + }, [tab, gallery.length, galleryTotal, galleryLoading]); const t = translations[language]; const it = immichTranslations[language]; const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]); @@ -98,13 +116,18 @@ export default function App() { const photos = useMemo(() => state.imageUrls, [state.imageUrls]); async function loadGallery(offset = 0, append = false) { + if (galleryLoadingRef.current) return; + galleryLoadingRef.current = true; setGalleryLoading(true); try { const page = await getGallery(offset, 48); setGallery(previous => append ? [...previous, ...page.items] : page.items); setGalleryTotal(page.total); } catch { setNotice(t.galleryLoadFailed); } - finally { setGalleryLoading(false); } + finally { + galleryLoadingRef.current = false; + setGalleryLoading(false); + } } async function update(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) { @@ -140,6 +163,17 @@ export default function App() { finally { setBusy(false); } } + function navigate(nextTab: Tab) { + if (tab === nextTab) return; + window.history.pushState({ ...window.history.state, wallpaperFlowTab: nextTab }, ""); + setTab(nextTab); + } + + function goBack(fallback: Tab) { + if (window.history.state?.wallpaperFlowTab === tab) window.history.back(); + else setTab(fallback); + } + function toggleSelected(id: string) { setSelectedIds(previous => { const next = new Set(previous); @@ -171,18 +205,18 @@ export default function App() { function openEditor(image: GalleryImage) { galleryScrollPosition.current = window.scrollY; setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) }); - setTab("editor"); + navigate("editor"); } function returnToGallery() { restoreGalleryScroll.current = true; - setTab("gallery"); + goBack("gallery"); } function openGallery() { galleryScrollPosition.current = 0; restoreGalleryScroll.current = false; - setTab("gallery"); + navigate("gallery"); void loadGallery(); } @@ -224,7 +258,7 @@ export default function App() { } async function openImmich() { - setTab("immich"); + navigate("immich"); setImmichSelected(new Set()); setImmichLoading(true); try { @@ -257,7 +291,7 @@ export default function App() { setState(await importImmichAssets([...immichSelected])); setImmichSelected(new Set()); setNotice(it.importSuccess); - setTab("home"); + navigate("home"); } catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); } finally { window.clearInterval(poll); @@ -397,10 +431,10 @@ export default function App() { } return
- {tab === "gallery" ?

{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}

{galleryTotal} {galleryTotal === 1 ? t.image : t.images}

{selectedIds.size ? : }
: tab === "editor" ? + {tab === "gallery" ?

{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}

{galleryTotal} {galleryTotal === 1 ? t.image : t.images}

{selectedIds.size ? : }
: tab === "editor" ?

{t.editImage}

{t.savedForImage}

: - tab === "immich" ?

{it.immich}

{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}

{immichSelected.size > 0 && }
: -

WallpaperFlow

{state.enabled ? t.automaticActive : t.automaticPaused}

} + tab === "immich" ?

{it.immich}

{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}

{immichSelected.size > 0 && }
: +

WallpaperFlow

{state.enabled ? t.automaticActive : t.automaticPaused}

}
{tab === "home" ? <> @@ -455,9 +489,9 @@ export default function App() { : tab === "gallery" ?
{!!galleryTotal &&
{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}
} - {gallery.length ?
{gallery.map((image, index) =>
{image.selected && {t.current}}
)}
: !galleryLoading &&

{t.emptyCollection}

{t.emptyCollectionText}

} + {gallery.length ?
{gallery.map((image, index) =>
{image.selected && {t.current}}
)}
: !galleryLoading &&

{t.emptyCollection}

{t.emptyCollectionText}

} {galleryLoading &&

{t.galleryLoading}

} - {!galleryLoading && gallery.length < galleryTotal && } + {gallery.length < galleryTotal &&
: editing &&
{t.cropPreview}
@@ -476,6 +510,6 @@ export default function App() {
{notice && } - {tab !== "gallery" && tab !== "editor" && tab !== "immich" && } + {tab !== "gallery" && tab !== "editor" && tab !== "immich" && }
; } diff --git a/src/styles.css b/src/styles.css index 3b8c694..fcd72ac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -149,6 +149,7 @@ nav svg { width: 21px; } .gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; } .gallery-empty .primary { max-width: 260px; } .gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; } +.gallery-load-sentinel { height: 1px; } .load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; } .save-crop { background: var(--green); color: white; } .crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }