Author SHA1 Message Date
iceBear67 0108def92d add google photos support & fix preview 2026-08-08 10:26:17 +00:00
iceBear67 b0a23544e3 bump version 2026-08-08 14:15:17 +08:00
iceBear67 4aa578121d Add: support for user-global configuration. 2026-08-08 14:14:11 +08:00
iceBear67 2f484157e6 Merge pull request #1 from iceBear67/imgbot
[ImgBot] Optimize images
2026-08-08 14:10:14 +08:00
ImgBotApp 941ad2c499 [ImgBot] Optimize images
*Total -- 1,454.01kb -> 1,250.03kb (14.03%)

/images/settings.png -- 198.01kb -> 154.76kb (21.84%)
/images/photo_preview.png -- 1,256.00kb -> 1,095.27kb (12.8%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-08-07 20:06:39 +00:00
37 changed files with 2563 additions and 61 deletions
+57 -5
View File
@@ -5,7 +5,8 @@
> fixed and replied by human.
A Fabric client mod that sends your Minecraft screenshots to [Immich](https://immich.app)
and lets you browse the album back inside the game. This mod also supports periodic screenshooting.
or [Google Photos](https://photos.google.com) and lets you browse the album back
inside the game. This mod also supports periodic screenshooting.
Take a screenshot with F2 and it uploads in the background. Press F6 to see what
is uploading, scroll through the album's timeline, or change how any of it
@@ -73,6 +74,49 @@ clients or worlds stays tellable apart.
Settings save when you close the screen. **Revert** undoes everything since you
opened it.
## Connect it to Google Photos
Press **F6****Settings** and switch **Provider** to Google Photos. The two
fields are an **OAuth client ID** and an **OAuth client secret**, and you have to
make them yourself — there is no key to paste from a Google Photos settings page,
and this mod cannot ship one for you. Google classes the Photos scopes as
restricted, so a shipped credential would need Google's verification, would name
this mod as the data controller for everyone's photos, and would break for
everybody at once the day it was revoked.
Making one is a five-minute detour through the
[Google Cloud console](https://console.cloud.google.com), once:
1. Create a project (any name).
2. **APIs & Services → Library**, find **Photos Library API**, press Enable.
3. **APIs & Services → OAuth consent screen**: pick External, fill in the three
required fields, and add your own Google address under **Test users**. The app
can stay unpublished — a test user is exactly what you are.
4. **APIs & Services → Credentials → Create credentials → OAuth client ID**,
application type **Desktop app**. Copy the client ID and client secret.
Paste both into Settings and press **Test**. Your browser opens Google's consent
page, you allow it, and the tab tells you to go back to the game; the settings
screen then shows the account you signed in as. Nothing is typed into Minecraft
except the two client fields, and the sign-in that comes back is kept in
`google-photos.json` next to your settings, readable only by your user account.
Three things are genuinely different from Immich, and all three are Google's
rules rather than choices made here:
- **Browse only shows what PhotoSync uploaded.** Google withdrew the broad
library scopes in March 2025. No application can read your Google Photos
account any more, so the Browse tab is a view of this mod's own screenshots —
not of your holiday photos.
- **Albums have to be ones PhotoSync made.** The album picker lists those only,
because an upload into any other album is refused. Type a name and press Create
and it will be there.
- **Retries can leave a duplicate.** The Library API has no checksum, no
duplicate response, and no way to ask whether something is already stored. If
the game dies between an upload finishing and its confirmation arriving, the
retry stores a second copy. Immich does not have this problem; Google's API
gives nothing to solve it with.
## The screens
**Uploads** is the queue: everything still in flight plus the last hundred that
@@ -148,7 +192,7 @@ Either way nothing is lost. The queue is on disk and is written through on every
state change, so unfinished uploads resume the next time you play — the dialog
exists to save you the wait, not to prevent a loss. An upload that was mid-flight
when the game died is simply tried again, and Immich recognises the retry as a
duplicate rather than storing a second copy.
duplicate rather than storing a second copy. (Google Photos cannot; see above.)
You can turn the dialog off under Settings → Uploads → *Ask before quitting
mid-upload*, in which case the game gets a three-second grace period to finish
@@ -169,11 +213,17 @@ whatever is nearly done and then quits regardless.
## Files it writes
Everything lives in `config/photosync/` in your game directory:
By default everything lives in `config/photosync/` in your game directory:
- `photosync.json` — settings, including your API key. Back it up accordingly.
- `uploads.json` — the upload queue, so it survives a crash or a quit.
Turn on *Store settings outside the game folder* in Settings → Storage and
`photosync.json` moves to `~/.config/photosyncmod/` on Linux and macOS, or
`%APPDATA%\photosyncmod\` on Windows, shared across your Minecraft installs.
The game folder keeps `uploads.json` and a small `location.json` pointer that
says where the settings file went.
Screenshots themselves go where they always did, in `screenshots/`. PhotoSync
never moves them, and only deletes one if you asked it to and the upload was
confirmed.
@@ -226,10 +276,12 @@ the way it is, [`docs/PORTING.md`](docs/PORTING.md) is the document for that: it
covers the bucket scheme, the measured API breakpoints between every supported
version, and the procedure for a new release.
Adding a photo service other than Immich means implementing `PhotoProvider` and
Adding a third photo service means implementing `PhotoProvider` and
`ProviderFactory` in `shared/core` and registering it with `ProviderCatalog`. No
UI or platform code needs to change — the settings screen builds its connection
fields from what the provider declares.
fields from what the provider declares. `provider/immich` is the simpler of the
two worked examples; `provider/google` is the one to read if the service needs
OAuth rather than a key.
## Licence
+1 -1
View File
@@ -7,7 +7,7 @@ org.gradle.configuration-cache=false
mod_id=photosync
mod_name=PhotoSync
mod_version=0.1.0
mod_version=0.2.0
mod_group=dev.photosync
mod_description=Sync your Minecraft screenshots to Immich, and browse your albums in game.
mod_license=MIT
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 155 KiB

@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -57,6 +59,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -84,6 +91,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
import dev.photosync.core.capture.CameraPose;
import dev.photosync.mcapi.GameContext;
import dev.photosync.platform.UserDataDirectory;
import dev.photosync.platform.WindowTitle;
import lombok.extern.slf4j.Slf4j;
import net.fabricmc.loader.api.FabricLoader;
@@ -23,6 +24,7 @@ public final class GameAdapter implements GameContext {
* loader's answers have not moved once in the supported range.
*/
private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync");
private final Path userDataDirectory = UserDataDirectory.resolve();
private final String minecraftVersion = FabricLoader.getInstance()
.getModContainer("minecraft")
.map(container -> container.getMetadata().getVersion().getFriendlyString())
@@ -58,6 +60,11 @@ public final class GameAdapter implements GameContext {
return configDirectory;
}
@Override
public Path userDataDirectory() {
return userDataDirectory;
}
@Override
public void submit(Runnable task) {
Minecraft game = Minecraft.getInstance();
@@ -85,6 +92,18 @@ public final class GameAdapter implements GameContext {
Util.getPlatform().openFile(target.toFile());
}
/**
* Hands a URL to whatever the desktop opens links with.
*
* <p>Minecraft's own opener, the one behind every link in the game's menus,
* rather than {@code java.awt.Desktop}: on macOS the game holds the main
* thread for GLFW and an AWT call from here would never come back.
*/
@Override
public void openUrl(String url) {
Util.getPlatform().openUri(url);
}
@Override
public String minecraftVersion() {
return minecraftVersion;
@@ -0,0 +1,25 @@
package dev.photosync.platform;
import java.nio.file.Path;
/**
* The OS-user-level directory PhotoSync offers for its settings file.
*
* <p>One rule, written once: Windows keeps per-user application data under
* {@code %APPDATA%}, everything else under {@code ~/.config} (the convention
* the Linux desktops and macOS share). Nine buckets compile this class, so a
* fix to the rule needs one edit instead of nine.
*/
public final class UserDataDirectory {
private UserDataDirectory() {
}
public static Path resolve() {
String appData = System.getenv("APPDATA");
Path base = appData == null || appData.isBlank()
? Path.of(System.getProperty("user.home"), ".config")
: Path.of(appData);
return base.resolve("photosyncmod");
}
}
@@ -2,6 +2,7 @@ package dev.photosync.client;
import dev.photosync.core.PhotoSync;
import dev.photosync.core.capture.CapturedScreenshot;
import dev.photosync.core.config.ConfigLocations;
import dev.photosync.core.config.NotificationKind;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.upload.UploadEvent;
@@ -52,7 +53,15 @@ public final class PhotoSyncClient implements AutoCloseable {
private final AutoCapture autoCapture;
public PhotoSyncClient(ClientBridge bridge) {
this(bridge, new PhotoSync(bridge.game().configDirectory()), Theme.dark());
this(bridge, new PhotoSync(
new ConfigLocations(
bridge.game().configDirectory(),
bridge.game().userDataDirectory()),
// How Google Photos gets the player in front of its consent
// page. Core cannot open a browser, and the sign-in is called
// from a worker, so this hops back to the render thread the way
// everything else that touches the game does.
url -> bridge.game().submit(() -> bridge.game().openUrl(url))), Theme.dark());
}
/** Takes its collaborators explicitly so a test can drive it without a game. */
@@ -98,6 +98,12 @@
"photosync.settings.cache": "Thumbnails kept in memory",
"photosync.settings.video_badge": "Mark videos in the grid",
"photosync.settings.section.storage": "Storage",
"photosync.settings.user_config_location": "Store settings outside the game folder",
"photosync.settings.user_config_location.detail": "Settings move to %s and are shared across your Minecraft installs. The upload queue stays in the game folder.",
"photosync.settings.config_folder": "Config folder",
"photosync.settings.show_folder": "Show in folder",
"photosync.settings.save": "Save",
"photosync.settings.revert": "Revert",
"photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.",
@@ -120,5 +126,10 @@
"photosync.provider.immich.endpoint": "Server URL",
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
"photosync.provider.immich.secret": "API key",
"photosync.provider.immich.secret.hint": "Account settings -> API Keys"
"photosync.provider.immich.secret.hint": "Account settings -> API Keys",
"photosync.provider.googlephotos.name": "Google Photos",
"photosync.provider.googlephotos.endpoint": "OAuth client ID",
"photosync.provider.googlephotos.endpoint.hint": "Google Cloud -> Credentials -> Desktop app",
"photosync.provider.googlephotos.secret": "OAuth client secret",
"photosync.provider.googlephotos.secret.hint": "From the same client; Test connection signs you in"
}
@@ -98,6 +98,12 @@
"photosync.settings.cache": "内存中保留的缩略图数量",
"photosync.settings.video_badge": "在网格中标记视频",
"photosync.settings.section.storage": "存储",
"photosync.settings.user_config_location": "将设置存储在游戏文件夹之外",
"photosync.settings.user_config_location.detail": "设置将移动到 %s,并在你的多个 Minecraft 安装之间共享。上传队列仍保留在游戏文件夹中。",
"photosync.settings.config_folder": "配置文件夹",
"photosync.settings.show_folder": "在文件夹中显示",
"photosync.settings.save": "保存",
"photosync.settings.revert": "还原",
"photosync.settings.dirty": "有未保存的更改——关闭此界面时保存。",
@@ -120,5 +126,10 @@
"photosync.provider.immich.endpoint": "服务器地址",
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
"photosync.provider.immich.secret": "API 密钥",
"photosync.provider.immich.secret.hint": "账户设置 -> API 密钥"
"photosync.provider.immich.secret.hint": "账户设置 -> API 密钥",
"photosync.provider.googlephotos.name": "Google 相册",
"photosync.provider.googlephotos.endpoint": "OAuth 客户端 ID",
"photosync.provider.googlephotos.endpoint.hint": "Google Cloud -> 凭据 -> 桌面应用",
"photosync.provider.googlephotos.secret": "OAuth 客户端密钥",
"photosync.provider.googlephotos.secret.hint": "同一客户端;点击“测试连接”完成登录"
}
@@ -1,11 +1,15 @@
package dev.photosync.core;
import dev.photosync.core.config.ConfigLocation;
import dev.photosync.core.config.ConfigLocations;
import dev.photosync.core.config.ConfigService;
import dev.photosync.core.config.ConfigStore;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.provider.ProviderCatalog;
import dev.photosync.core.provider.ProviderFactory;
import dev.photosync.core.provider.ProviderSession;
import dev.photosync.core.provider.google.GooglePhotosProviderFactory;
import dev.photosync.core.provider.google.SignInPrompt;
import dev.photosync.core.provider.immich.ImmichProviderFactory;
import dev.photosync.core.thumbnail.ThumbnailLoader;
import dev.photosync.core.timeline.TimelineBrowser;
@@ -14,7 +18,10 @@ import dev.photosync.core.upload.UploadQueue;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.List;
@@ -22,9 +29,9 @@ import java.util.List;
* Everything the mod does that is not Minecraft, assembled.
*
* <p>This is the seam the platform code sees: a Fabric entrypoint builds one of
* these with a config directory and then only ever talks to the services hanging
* off it. Nothing below this package knows what a {@code Screen} is, and nothing
* here reaches back up.
* these with a {@link ConfigLocations} -- where the settings file may live --
* and then only ever talks to the services hanging off it. Nothing below this
* package knows what a {@code Screen} is, and nothing here reaches back up.
*
* <p>The wiring is done in a constructor rather than by a container because
* there are seven objects and their order is fixed. The one piece of behaviour
@@ -36,6 +43,11 @@ public final class PhotoSync implements AutoCloseable {
private static final String CONFIG_FILE = "photosync.json";
private static final String QUEUE_FILE = "uploads.json";
private static final String TOKEN_FILE = GooglePhotosProviderFactory.TOKEN_FILE;
@Getter
private final ConfigLocations locations;
private volatile ConfigLocation activeLocation;
@Getter
private final ProviderCatalog catalog;
@@ -52,19 +64,61 @@ public final class PhotoSync implements AutoCloseable {
@Getter
private final ThumbnailLoader thumbnails;
/** The standard set of backends. */
/** The standard set of backends, stored in the given directory. */
public PhotoSync(Path directory) {
this(directory, List.of(new ImmichProviderFactory()));
this(ConfigLocations.fixed(directory));
}
/** Takes the backend list explicitly so tests can run against a fake one. */
public PhotoSync(Path directory, List<ProviderFactory> providers) {
this(ConfigLocations.fixed(directory), providers);
}
/** The standard set of backends, with a switchable storage location. */
public PhotoSync(ConfigLocations locations) {
this(locations, SignInPrompt.logging());
}
/**
* The standard set of backends, told how to put the player in front of a
* browser.
*
* <p>The prompt exists for Google Photos alone: it is the only backend whose
* credential cannot be typed into the settings screen. Without one the
* sign-in URL still appears in the log and still works, which is what the
* other constructors fall back to.
*/
public PhotoSync(ConfigLocations locations, SignInPrompt signIn) {
this(locations, standardProviders(locations, signIn));
}
/**
* Immich first, deliberately: {@code ProviderCatalog.preferred()} is the
* head of this list, and it is what a fresh install starts configured for.
*/
private static List<ProviderFactory> standardProviders(ConfigLocations locations, SignInPrompt signIn) {
return List.of(
new ImmichProviderFactory(),
new GooglePhotosProviderFactory(
// Resolved on each use rather than captured: the settings
// directory can change while the game is running, and the
// token has to follow the settings it belongs to.
() -> locations.directory(locations.resolve()).resolve(TOKEN_FILE),
signIn));
}
/** Takes the backend list explicitly so tests can run against a fake one. */
public PhotoSync(ConfigLocations locations, List<ProviderFactory> providers) {
this.locations = locations;
this.activeLocation = locations.resolve();
this.catalog = new ProviderCatalog(providers);
this.config = new ConfigService(new ConfigStore(directory.resolve(CONFIG_FILE), catalog.preferred()));
this.config = new ConfigService(new ConfigStore(
locations.directory(activeLocation).resolve(CONFIG_FILE), catalog.preferred()));
this.session = new ProviderSession(catalog);
// Reads and repairs the on-disk queue, so anything interrupted by the
// last quit is already pending again by the time uploads start.
this.queue = new UploadQueue(directory.resolve(QUEUE_FILE));
// The queue stays in the instance directory even when the settings file
// moves: its entries reference screenshot paths that are local to the
// instance, so a shared queue would point at the wrong files.
this.queue = new UploadQueue(locations.directory(ConfigLocation.INSTANCE).resolve(QUEUE_FILE));
this.uploads = new UploadCoordinator(queue, session, () -> config.current().upload());
this.browser = new TimelineBrowser(session);
this.thumbnails = new ThumbnailLoader(session);
@@ -73,7 +127,9 @@ public final class PhotoSync implements AutoCloseable {
/** Connects to the configured backend and starts draining the queue. */
public PhotoSync start() {
config.onChange(this::applyConnection);
config.onChange(this::applyLocation);
applyConnection(config.current());
reconcileLocation();
uploads.start();
int resumed = queue.activeCount();
if (resumed > 0) {
@@ -103,6 +159,65 @@ public final class PhotoSync implements AutoCloseable {
uploads.wake();
}
/** Where the settings file currently lives. */
public Path dataDirectory() {
return locations.directory(activeLocation);
}
/**
* Moves the settings file when the player switches storage location.
*
* <p>The upload queue is deliberately left in the instance directory; only
* the settings file follows the choice. The marker is written last, so a
* failure part-way leaves the old location authoritative for the next
* launch.
*/
private void applyLocation(PhotoSyncConfig current) {
ConfigLocation next = current.location();
if (next == activeLocation) {
return;
}
try {
Path from = locations.directory(activeLocation);
Path to = locations.directory(next);
if (from.equals(to)) {
// A catalog with no real user directory (tests): nothing to move.
return;
}
Files.createDirectories(to);
moveFile(from.resolve(CONFIG_FILE), to.resolve(CONFIG_FILE));
// The Google Photos sign-in travels with the settings it belongs to.
// Leaving it behind would silently sign the player out the next time
// they opened the settings screen.
moveFile(from.resolve(TOKEN_FILE), to.resolve(TOKEN_FILE));
config.relocate(to.resolve(CONFIG_FILE));
locations.writeMarker(next);
activeLocation = next;
log.info("PhotoSync settings moved to {}", to);
} catch (IOException e) {
log.error("Could not move PhotoSync settings to {}; keeping them in {}",
locations.directory(next), locations.directory(activeLocation), e);
config.update(previous -> previous.toBuilder().location(activeLocation).build());
}
}
private static void moveFile(Path source, Path target) throws IOException {
if (Files.exists(source)) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Brings the settings file's own location field in line with the marker.
* They can only disagree when the file was hand-edited; the marker is the
* truth about where the file actually is.
*/
private void reconcileLocation() {
if (config.current().location() != activeLocation) {
config.update(previous -> previous.toBuilder().location(activeLocation).build());
}
}
@Override
public void close() {
thumbnails.close();
@@ -0,0 +1,11 @@
package dev.photosync.core.config;
/** Where PhotoSync keeps its settings file. */
public enum ConfigLocation {
/** {@code config/photosync} inside the game directory -- the historical default, per instance. */
INSTANCE,
/** The OS-user-level directory, shared across Minecraft installs. */
USER
}
@@ -0,0 +1,70 @@
package dev.photosync.core.config;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import dev.photosync.core.persistence.JsonFile;
import java.io.IOException;
import java.nio.file.Path;
/**
* The two places PhotoSync's settings file may live, and the record of which
* one is active.
*
* <p>Which location is active cannot be stored inside the settings file itself,
* because the mod has to know where that file is before it can read it. So the
* choice is recorded in a small marker ({@code location.json}) that always
* lives in the instance directory: start-up reads the marker to pick a
* directory, and the settings screen rewrites it when the player switches. A
* missing or unreadable marker means the instance directory, which is also what
* every existing installation has, so nothing changes for them.
*/
public final class ConfigLocations {
private static final String MARKER_FILE = "location.json";
private final Path instanceDirectory;
private final Path userDirectory;
private final JsonFile marker;
public ConfigLocations(Path instanceDirectory, Path userDirectory) {
this.instanceDirectory = instanceDirectory;
this.userDirectory = userDirectory;
this.marker = new JsonFile(instanceDirectory.resolve(MARKER_FILE));
}
/** A catalog that offers no user directory, for tests and the plain constructor. */
public static ConfigLocations fixed(Path directory) {
return new ConfigLocations(directory, directory);
}
/** The active location: what the marker says, or the instance when it says nothing. */
public ConfigLocation resolve() {
JsonElement stored = marker.readTree().orElse(null);
if (stored == null || !stored.isJsonObject()) {
return ConfigLocation.INSTANCE;
}
JsonElement value = stored.getAsJsonObject().get("location");
if (value == null || !value.isJsonPrimitive()) {
return ConfigLocation.INSTANCE;
}
try {
return ConfigLocation.valueOf(value.getAsString());
} catch (IllegalArgumentException e) {
// A hand-edited marker names something that is not a location.
return ConfigLocation.INSTANCE;
}
}
/** The directory a location maps to. */
public Path directory(ConfigLocation location) {
return location == ConfigLocation.USER ? userDirectory : instanceDirectory;
}
/** Records the active location so the next launch looks in the right place. */
public void writeMarker(ConfigLocation location) throws IOException {
JsonObject written = new JsonObject();
written.addProperty("location", location.name());
marker.write(written);
}
}
@@ -3,6 +3,7 @@ package dev.photosync.core.config;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
@@ -61,4 +62,9 @@ public final class ConfigService {
log.error("Could not write {}", store.path(), e);
}
}
/** Points the store at a new file, after the settings file has been moved there. */
public void relocate(Path path) {
store.relocate(path);
}
}
@@ -25,7 +25,7 @@ import java.util.Optional;
@Slf4j
public final class ConfigStore {
private final JsonFile file;
private JsonFile file;
private final ProviderId fallbackProvider;
public ConfigStore(Path path, ProviderId fallbackProvider) {
@@ -37,6 +37,11 @@ public final class ConfigStore {
return file.path();
}
/** Points the store at a new file, after the settings file has been moved there. */
public void relocate(Path path) {
this.file = new JsonFile(path);
}
/** Never throws: a broken config falls back to defaults rather than blocking start-up. */
public PhotoSyncConfig load() {
PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider);
@@ -21,7 +21,8 @@ public record PhotoSyncConfig(
UploadSettings upload,
AutoCaptureSettings autoCapture,
NotificationSettings notifications,
BrowserSettings browser) {
BrowserSettings browser,
ConfigLocation location) {
public static PhotoSyncConfig defaults(ProviderId provider) {
return PhotoSyncConfig.builder()
@@ -32,6 +33,7 @@ public record PhotoSyncConfig(
.autoCapture(AutoCaptureSettings.defaults())
.notifications(NotificationSettings.defaults())
.browser(BrowserSettings.defaults())
.location(ConfigLocation.INSTANCE)
.build();
}
@@ -49,6 +51,7 @@ public record PhotoSyncConfig(
.autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized())
.notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized())
.browser((browser == null ? BrowserSettings.defaults() : browser).normalized())
.location(location == null ? ConfigLocation.INSTANCE : location)
.build();
}
@@ -0,0 +1,451 @@
package dev.photosync.core.provider.google;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.photosync.core.persistence.JsonFile;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderException;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
import java.util.function.Supplier;
/**
* The OAuth half of talking to Google Photos: getting a token, keeping it, and
* getting a new one when it expires.
*
* <p>Google Photos has no API key. The only credential a player can hold is a
* refresh token, and the only way to mint one is to send them to Google in a
* browser and catch the redirect. So this class does three things: it runs that
* flow once ({@link #signIn()}), it stores what comes back next to the settings
* file, and from then on it quietly trades the refresh token for access tokens
* ({@link #bearer()}).
*
* <p>The split between those two entry points is the important design decision
* here. {@link #signIn()} can open a browser window; {@link #bearer()} never
* can. An upload retrying in the background must not throw a consent page over
* the game, so it fails with an authentication error instead and the player
* signs in when <em>they</em> choose to, from the settings screen.
*
* <p>PKCE is used even though this is a confidential client with a secret,
* because the secret is sitting in a config file on a player's machine and the
* loopback redirect is reachable by anything else running as that user. It
* costs two hashes.
*/
@Slf4j
final class GoogleAuth {
private static final URI AUTHORIZATION_ENDPOINT = URI.create("https://accounts.google.com/o/oauth2/v2/auth");
private static final URI TOKEN_ENDPOINT = URI.create("https://oauth2.googleapis.com/token");
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
/** How long the player gets to find the browser window and press Allow. */
private static final Duration CONSENT_TIMEOUT = Duration.ofMinutes(5);
/** Renew early: an access token that expires halfway through an upload is a failed upload. */
private static final Duration EXPIRY_MARGIN = Duration.ofMinutes(5);
/** What Google actually issues, used only when a response omits the field. */
private static final long DEFAULT_LIFETIME_SECONDS = 3600;
/**
* What PhotoSync asks Google for.
*
* <p>Google withdrew the broad library scopes in March 2025, so no app can
* read a user's whole Google Photos account any more. What is left happens
* to be exactly this mod's shape: permission to add media, and permission to
* read back the media it added. The consequence is worth being blunt about,
* because it surprises people -- the Browse tab shows the screenshots
* PhotoSync uploaded, not the player's holiday photos.
*
* <p>{@code openid} and the email scope are here only so that "Test
* connection" can name the account that was signed in. Without them the
* settings screen can say the token works but not whose it is, and a player
* with two Google accounts has no way to check they picked the right one.
*/
private static final String SCOPES = String.join(" ",
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/photoslibrary.appendonly",
"https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata");
/** What is kept on disk between sessions. The client id is recorded so a changed one invalidates it. */
private record StoredSignIn(String clientId, String refreshToken, String account) {
}
private final HttpClient http;
private final SecureRandom random = new SecureRandom();
private final Object lock = new Object();
private final String clientId;
private final String clientSecret;
private final Supplier<Path> tokenFile;
private final SignInPrompt prompt;
private String accessToken;
private Instant expiresAt = Instant.EPOCH;
private String refreshToken;
private String account = "";
private boolean loaded;
GoogleAuth(ProviderConnection connection, Supplier<Path> tokenFile, SignInPrompt prompt) {
this.clientId = connection.endpoint();
this.clientSecret = connection.secret();
this.tokenFile = tokenFile;
this.prompt = prompt;
this.http = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
/**
* The one connection pool for everything PhotoSync says to Google.
*
* <p>{@link GooglePhotosApi} borrows it rather than building a second: the
* token endpoint and the Library API are the same company's TLS termination,
* and a mod running inside a game should not hold two pools to reach it.
*/
HttpClient http() {
return http;
}
/** Whoever was signed in, once anything has asked for a token. Empty before that. */
String account() {
synchronized (lock) {
return account;
}
}
/**
* An access token for an API call, without ever prompting.
*
* <p>Fails rather than signing in, because this is reached from uploads and
* thumbnail fetches -- work that happens while the player is doing something
* else, where a browser window opening unbidden would be alarming.
*/
String bearer() throws ProviderException {
synchronized (lock) {
String usable = unexpired();
if (usable != null) {
return usable;
}
load();
if (refreshToken == null) {
throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
"PhotoSync is not signed in to Google Photos. "
+ "Open the settings screen and use Test connection to sign in.");
}
return refresh();
}
}
/**
* Makes sure there is a working sign-in, running the browser flow if there
* is not, and answers which account it belongs to.
*
* <p>This is what "Test connection" calls. It is the only path that may
* block for minutes, and it is a direct answer to a button the player just
* pressed.
*/
String signIn() throws ProviderException {
synchronized (lock) {
load();
if (refreshToken != null) {
try {
refresh();
return account;
} catch (ProviderException e) {
if (e.kind() != ProviderException.Kind.AUTHENTICATION) {
throw e;
}
// Revoked from the Google account page, or the client id in
// the settings was changed. Either way, start over.
log.info("The stored Google Photos sign-in no longer works; asking for a new one");
refreshToken = null;
}
}
authorize();
return account;
}
}
/**
* Throws away the current access token so the next {@link #bearer()} mints a
* fresh one.
*
* <p>For the case the expiry margin cannot cover: Google saying 401 to a
* token this class still believes in. A clock that is wrong, a laptop
* suspended mid-request, or a grant revoked and re-issued all look like
* that, and the answer to all three is to stop trusting what is cached.
*/
void forgetAccessToken() {
synchronized (lock) {
accessToken = null;
expiresAt = Instant.EPOCH;
}
}
private String unexpired() {
boolean fresh = accessToken != null && Instant.now().isBefore(expiresAt.minus(EXPIRY_MARGIN));
return fresh ? accessToken : null;
}
// -----------------------------------------------------------------------
// The browser flow
// -----------------------------------------------------------------------
private void authorize() throws ProviderException {
String verifier = randomToken(64);
String state = randomToken(24);
try (LoopbackReceiver receiver = new LoopbackReceiver()) {
String redirect = receiver.redirectUri();
Map<String, String> query = new LinkedHashMap<>();
query.put("client_id", clientId);
query.put("redirect_uri", redirect);
query.put("response_type", "code");
query.put("scope", SCOPES);
query.put("code_challenge", challengeFor(verifier));
query.put("code_challenge_method", "S256");
// Offline access is what makes Google issue a refresh token at all,
// and it only does so on a consent it has not already recorded --
// hence the forced prompt, which is the difference between signing
// in once and signing in every hour.
query.put("access_type", "offline");
query.put("prompt", "consent");
query.put("state", state);
String url = AUTHORIZATION_ENDPOINT + "?" + encode(query);
log.info("Waiting for a Google Photos sign-in from the browser on {}", redirect);
prompt.show(url);
Map<String, String> answer = receiver.awaitRedirect(CONSENT_TIMEOUT);
String refusal = answer.get("error");
if (refusal != null) {
throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
"Google would not sign PhotoSync in: " + refusal);
}
if (!state.equals(answer.get("state"))) {
// Somebody other than the browser we sent got to the port first.
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"The sign-in that came back was not the one PhotoSync started");
}
String code = answer.get("code");
if (code == null || code.isBlank()) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google's redirect carried no authorization code");
}
Map<String, String> form = new LinkedHashMap<>();
form.put("grant_type", "authorization_code");
form.put("code", code);
form.put("client_id", clientId);
form.put("client_secret", clientSecret);
form.put("code_verifier", verifier);
form.put("redirect_uri", redirect);
adopt(exchange(form));
log.info("Signed in to Google Photos as {}", account.isEmpty() ? "an unnamed account" : account);
} catch (SocketTimeoutException e) {
throw new ProviderException(ProviderException.Kind.AUTHENTICATION,
"The Google Photos sign-in was not finished in time. Press Test connection to try again.", e);
} catch (IOException e) {
throw new ProviderException(ProviderException.Kind.NETWORK,
"Could not listen for Google's answer: " + e.getMessage(), e);
}
}
private String refresh() throws ProviderException {
Map<String, String> form = new LinkedHashMap<>();
form.put("grant_type", "refresh_token");
form.put("refresh_token", refreshToken);
form.put("client_id", clientId);
form.put("client_secret", clientSecret);
adopt(exchange(form));
return accessToken;
}
/** Takes whatever a grant carried, keeping what it did not mention. */
private void adopt(JsonObject grant) {
accessToken = text(grant, "access_token");
long lifetime = grant.has("expires_in")
? grant.get("expires_in").getAsLong()
: DEFAULT_LIFETIME_SECONDS;
expiresAt = Instant.now().plusSeconds(lifetime);
// A refresh response repeats neither the refresh token nor, always, the
// identity: absence means unchanged, not revoked.
String issued = text(grant, "refresh_token");
if (issued != null) {
refreshToken = issued;
}
String email = emailIn(text(grant, "id_token"));
if (email != null) {
account = email;
}
store();
}
private JsonObject exchange(Map<String, String> form) throws ProviderException {
HttpRequest request = HttpRequest.newBuilder(TOKEN_ENDPOINT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.timeout(REQUEST_TIMEOUT)
.POST(HttpRequest.BodyPublishers.ofString(encode(form), StandardCharsets.UTF_8))
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
JsonObject body = objectIn(response.body());
if (response.statusCode() / 100 == 2 && body.has("access_token")) {
return body;
}
throw failure(response.statusCode(), body);
} catch (IOException e) {
throw new ProviderException(ProviderException.Kind.NETWORK,
"Could not reach Google's token endpoint: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while signing in to Google", e);
}
}
private static ProviderException failure(int status, JsonObject body) {
String code = text(body, "error");
String detail = text(body, "error_description");
ProviderException.Kind kind;
if ("invalid_grant".equals(code) || "invalid_client".equals(code) || status == 401 || status == 403) {
// invalid_grant is what a revoked or expired refresh token looks
// like, and it is the one case where signing in again is the fix.
kind = ProviderException.Kind.AUTHENTICATION;
} else if (status >= 500) {
kind = ProviderException.Kind.SERVER;
} else if (status == 429) {
kind = ProviderException.Kind.RATE_LIMITED;
} else {
kind = ProviderException.Kind.PROTOCOL;
}
return new ProviderException(kind, "Google refused the sign-in (HTTP " + status + ", "
+ (code == null ? "no details" : code) + (detail == null ? "" : ": " + detail) + ")");
}
// -----------------------------------------------------------------------
// The token on disk
// -----------------------------------------------------------------------
private void load() {
if (loaded) {
return;
}
loaded = true;
JsonFile file = new JsonFile(tokenFile.get());
JsonElement stored = file.readTree().orElse(null);
if (stored == null || !stored.isJsonObject()) {
return;
}
try {
StoredSignIn signIn = file.gson().fromJson(stored, StoredSignIn.class);
// A refresh token belongs to the OAuth client that minted it. If the
// player has pasted a different client id, this one is dead weight.
if (signIn == null || !clientId.equals(signIn.clientId())) {
return;
}
refreshToken = signIn.refreshToken() == null || signIn.refreshToken().isBlank()
? null
: signIn.refreshToken();
account = signIn.account() == null ? "" : signIn.account();
} catch (RuntimeException e) {
log.warn("Could not read the Google Photos sign-in in {}; signing in again", file.path(), e);
}
}
private void store() {
JsonFile file = new JsonFile(tokenFile.get());
try {
file.write(file.gson().toJsonTree(new StoredSignIn(clientId, refreshToken, account)));
} catch (IOException e) {
// The sign-in still works for this session. It just will not survive
// a restart, which is worth a warning and not worth failing over.
log.warn("Could not save the Google Photos sign-in to {}", file.path(), e);
}
}
// -----------------------------------------------------------------------
private String randomToken(int bytes) {
byte[] value = new byte[bytes];
random.nextBytes(value);
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
private static String challengeFor(String verifier) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(sha256.digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Every JVM is required to provide SHA-256", e);
}
}
/**
* The email claim out of an OpenID token, or null.
*
* <p>Read rather than verified: this is Google's own answer, arriving over
* TLS from Google's own token endpoint, and it is used for one thing --
* putting a name next to "Test connection". Nothing is authorised by it.
*/
private static String emailIn(String idToken) {
if (idToken == null) {
return null;
}
String[] parts = idToken.split("\\.");
if (parts.length < 2) {
return null;
}
try {
JsonObject claims = objectIn(new String(
Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
return text(claims, "email");
} catch (RuntimeException e) {
log.debug("Could not read the account name out of Google's id token: {}", e.toString());
return null;
}
}
/** Whatever object is in {@code body}, or an empty one -- an unparseable answer is a missing answer. */
private static JsonObject objectIn(String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
JsonElement parsed = JsonParser.parseString(body);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private static String text(JsonObject body, String member) {
JsonElement value = body.get(member);
return value == null || !value.isJsonPrimitive() ? null : value.getAsString();
}
private static String encode(Map<String, String> values) {
StringJoiner joiner = new StringJoiner("&");
values.forEach((name, value) -> joiner.add(
URLEncoder.encode(name, StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(value, StandardCharsets.UTF_8)));
return joiner.toString();
}
}
@@ -0,0 +1,93 @@
package dev.photosync.core.provider.google;
import java.util.List;
/**
* The slices of the Google Photos Library API v1 that PhotoSync reads, mirrored
* as records for Gson.
*
* <p>The API is camelCase throughout, so these need no name mapping. Two of its
* conventions do need remembering, and both are why several fields below are
* {@code String} where a number would be expected:
*
* <ul>
* <li>Google's JSON encodes every 64-bit integer as a string, so
* {@code mediaItemsCount} arrives as {@code "42"}.</li>
* <li>{@code width} and {@code height} are documented the same way, even
* though no photo is 2^53 pixels wide.</li>
* </ul>
*
* <p>Fields the mod does not use are simply absent; Gson ignores unknown JSON
* members, and the smaller this file is the fewer ways an API change breaks us.
*/
final class GoogleDtos {
private GoogleDtos() {
}
/**
* One photo or video.
*
* <p>{@code baseUrl} is the only way to get pixels, it is not a stable
* identifier, and it stops working roughly an hour after it was issued --
* see {@code GooglePhotosProvider#thumbnail}.
*/
record MediaItem(
String id,
String description,
String baseUrl,
String mimeType,
String filename,
MediaMetadata mediaMetadata) {
}
/** {@code creationTime} is RFC 3339 in UTC; the width and height are decimal strings. */
record MediaMetadata(String creationTime, String width, String height) {
}
record MediaItemsResponse(List<MediaItem> mediaItems, String nextPageToken) {
}
/** Body for {@code mediaItems:search}. Only set the fields being used; nulls are omitted by Gson. */
record SearchRequest(String albumId, Integer pageSize, String pageToken) {
}
record AlbumSummary(String id, String title, String mediaItemsCount, String coverPhotoMediaItemId) {
}
record AlbumsResponse(List<AlbumSummary> albums, String nextPageToken) {
}
record CreateAlbumRequest(NewAlbum album) {
}
record NewAlbum(String title) {
}
record BatchCreateRequest(String albumId, List<NewMediaItem> newMediaItems) {
}
record NewMediaItem(String description, SimpleMediaItem simpleMediaItem) {
}
/** {@code uploadToken} is what {@code /v1/uploads} returned, as plain text. */
record SimpleMediaItem(String uploadToken, String fileName) {
}
record BatchCreateResponse(List<NewMediaItemResult> newMediaItemResults) {
}
/** A per-item outcome: {@code status.code} 0 means this one was created. */
record NewMediaItemResult(String uploadToken, Status status, MediaItem mediaItem) {
}
record Status(Integer code, String message) {
}
/** Google's standard error envelope, which every endpoint here shares. */
record ApiError(ErrorBody error) {
}
record ErrorBody(Integer code, String message, String status) {
}
}
@@ -0,0 +1,339 @@
package dev.photosync.core.provider.google;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.TransferCancelledException;
import dev.photosync.core.provider.TransferProgress;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.StringJoiner;
/**
* Everything HTTP about talking to the Google Photos Library API: bearer
* tokens, timeouts, the upload protocol, and turning a status code into a
* {@link ProviderException} the upload queue can act on.
*
* <p>Kept apart from {@link GooglePhotosProvider} for the same reason Immich's
* is: the provider then reads as a list of API calls and their mapping onto
* PhotoSync's model, with no transport noise in between.
*/
final class GooglePhotosApi {
private static final URI ROOT = URI.create("https://photoslibrary.googleapis.com");
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
/** Uploads get their own budget: a screenshot over a slow uplink is not a hung server. */
private static final Duration UPLOAD_TIMEOUT = Duration.ofMinutes(10);
/** Report progress at most this often, so a fast link is not drowned in callbacks. */
private static final int PROGRESS_STRIDE_BYTES = 64 * 1024;
private final HttpClient http;
private final GoogleAuth auth;
private final Gson gson = new Gson();
GooglePhotosApi(GoogleAuth auth) {
this.auth = auth;
this.http = auth.http();
}
<T> T get(String path, Map<String, String> query, Type type) throws ProviderException {
HttpRequest.Builder request = HttpRequest.newBuilder(uri(path, query))
.header("Accept", "application/json")
.timeout(REQUEST_TIMEOUT)
.GET();
return decode(send(request), type);
}
<T> T post(String path, Object body, Type type) throws ProviderException {
HttpRequest.Builder request = HttpRequest.newBuilder(uri(path, Map.of()))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.timeout(REQUEST_TIMEOUT)
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8));
return decode(send(request), type);
}
/**
* Streams a file to {@code /v1/uploads} and returns the upload token that
* {@code mediaItems:batchCreate} turns into a real media item.
*
* <p>This endpoint is not JSON in either direction. The body is the raw file
* -- which is why it is streamed from disk rather than read into a byte
* array, on a heap the game is already competing for -- and the answer is
* the token as bare text with no envelope around it.
*/
String upload(Path file, String mimeType, TransferProgress progress) throws ProviderException {
long size;
try {
size = Files.size(file);
} catch (IOException e) {
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
}
HttpRequest.Builder request = HttpRequest.newBuilder(uri("/v1/uploads", Map.of()))
.header("Content-Type", "application/octet-stream")
.header("X-Goog-Upload-Content-Type", mimeType)
// "raw" is the whole file in one request. The alternative,
// "resumable", buys the ability to continue an interrupted
// upload -- worth having for a video, not for a screenshot,
// and it costs two extra round trips on every one of them.
.header("X-Goog-Upload-Protocol", "raw")
.timeout(UPLOAD_TIMEOUT)
.POST(streamOf(file, size, progress));
String token = new String(send(request), StandardCharsets.UTF_8).trim();
if (token.isEmpty()) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google accepted the upload but returned no upload token");
}
return token;
}
/**
* Fetches image bytes from a {@code baseUrl}.
*
* <p>Deliberately unauthenticated. These URLs carry their own signed
* credential -- Google's own samples put them straight into an
* {@code <img src>} -- and sending a bearer token to a host outside the API
* endpoint is a habit worth not having.
*/
byte[] fetch(String url) throws ProviderException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.GET()
.build();
try {
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() / 100 == 2) {
return response.body();
}
throw failure(response.statusCode(), response.body());
} catch (IOException e) {
throw new ProviderException(ProviderException.Kind.NETWORK,
"Could not download the image from Google: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while downloading from Google", e);
}
}
// -----------------------------------------------------------------------
/**
* Sends with a bearer token, and once more with a fresh one if Google says
* the first was no good.
*
* <p>An access token lives an hour and {@link GoogleAuth} renews it five
* minutes early, so this should not happen -- but a clock that is wrong, a
* session suspended mid-request, or a token revoked and re-granted all land
* here, and re-asking once is cheaper than failing an upload the player
* would have to notice and retry.
*/
private byte[] send(HttpRequest.Builder builder) throws ProviderException {
try {
return send(builder.copy().header("Authorization", "Bearer " + auth.bearer()).build());
} catch (ProviderException e) {
if (e.kind() != ProviderException.Kind.AUTHENTICATION) {
throw e;
}
auth.forgetAccessToken();
return send(builder.copy().header("Authorization", "Bearer " + auth.bearer()).build());
}
}
private byte[] send(HttpRequest request) throws ProviderException {
try {
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status >= 200 && status < 300) {
return response.body();
}
throw failure(status, response.body());
} catch (IOException e) {
// A cancelled transfer unwinds through the request body stream and
// the client hands it back wrapped. It is not a network failure and
// must not be retried, so dig it out before classifying anything.
TransferCancelledException cancelled = findCancellation(e);
if (cancelled != null) {
throw cancelled;
}
throw new ProviderException(ProviderException.Kind.NETWORK,
"Could not reach Google Photos: " + e.getMessage(), e);
} catch (UncheckedIOException e) {
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE,
"Could not read the file to upload: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while waiting for Google", e);
}
}
private ProviderException failure(int status, byte[] body) {
ProviderException.Kind kind = switch (status) {
case 401 -> ProviderException.Kind.AUTHENTICATION;
// 403 is overloaded here. Google returns it both for a token that
// may not do this and for a project that has run out of quota, and
// only the message tells them apart -- so the message decides
// whether the queue waits or gives up.
case 403 -> looksLikeQuota(body)
? ProviderException.Kind.RATE_LIMITED
: ProviderException.Kind.AUTHENTICATION;
case 404 -> ProviderException.Kind.NOT_FOUND;
case 408, 429 -> ProviderException.Kind.RATE_LIMITED;
default -> status >= 500 ? ProviderException.Kind.SERVER : ProviderException.Kind.PROTOCOL;
};
return new ProviderException(kind, "Google Photos returned HTTP " + status + " (" + describe(body) + ")");
}
private boolean looksLikeQuota(byte[] body) {
String status = messageOf(body).orElse("");
return status.contains("quota") || status.contains("Quota") || status.contains("rate limit");
}
/** Google answers errors as JSON; fall back to the raw text when it does not. */
private String describe(byte[] body) {
String text = new String(body, StandardCharsets.UTF_8).trim();
return messageOf(body)
.filter(message -> !message.isBlank())
.orElseGet(() -> text.isEmpty() ? "no details" : abbreviate(text));
}
private Optional<String> messageOf(byte[] body) {
String text = new String(body, StandardCharsets.UTF_8).trim();
if (!text.startsWith("{")) {
return Optional.empty();
}
try {
GoogleDtos.ApiError error = gson.fromJson(text, GoogleDtos.ApiError.class);
return Optional.ofNullable(error)
.map(GoogleDtos.ApiError::error)
.map(GoogleDtos.ErrorBody::message);
} catch (JsonSyntaxException ignored) {
return Optional.empty();
}
}
private <T> T decode(byte[] body, Type type) throws ProviderException {
try {
T value = gson.fromJson(new String(body, StandardCharsets.UTF_8), type);
if (value == null) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google Photos returned an empty response");
}
return value;
} catch (JsonSyntaxException e) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google Photos returned something that is not the JSON we expected", e);
}
}
private static URI uri(String path, Map<String, String> query) {
StringBuilder url = new StringBuilder(ROOT.toString()).append(path);
if (!query.isEmpty()) {
StringJoiner joiner = new StringJoiner("&");
query.forEach((key, value) -> joiner.add(
URLEncoder.encode(key, StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(value, StandardCharsets.UTF_8)));
url.append('?').append(joiner);
}
return URI.create(url.toString());
}
/**
* The file as a request body with a real {@code Content-Length}, reporting
* bytes as the client pulls them.
*
* <p>The supplier may be called more than once -- a redirect replays the
* body -- so it opens a fresh stream each time rather than capturing one.
*/
private static HttpRequest.BodyPublisher streamOf(Path file, long size, TransferProgress progress) {
return HttpRequest.BodyPublishers.fromPublisher(
HttpRequest.BodyPublishers.ofInputStream(() -> {
try {
return new CountingStream(Files.newInputStream(file), progress, size);
} catch (IOException e) {
// The supplier cannot throw a checked exception; the
// HttpClient unwraps this back into one on send().
throw new UncheckedIOException(e);
}
}),
size);
}
/**
* Counts bytes as the HTTP client pulls them, and tells the queue.
*
* <p>The callback may throw -- that is how a cancelled upload unwinds -- so
* it is invoked where the exception can propagate out of {@code read}.
*/
private static final class CountingStream extends FilterInputStream {
private final TransferProgress progress;
private final long total;
private long sent;
private long lastReported;
CountingStream(InputStream delegate, TransferProgress progress, long total) {
super(delegate);
this.progress = progress;
this.total = total;
}
@Override
public int read() throws IOException {
int value = super.read();
if (value >= 0) {
advance(1);
}
return value;
}
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
int count = super.read(buffer, offset, length);
if (count > 0) {
advance(count);
}
return count;
}
private void advance(int count) {
sent += count;
if (sent - lastReported >= PROGRESS_STRIDE_BYTES || sent == total) {
lastReported = sent;
progress.onProgress(sent, total);
}
}
}
private static TransferCancelledException findCancellation(Throwable thrown) {
for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
if (cause instanceof TransferCancelledException cancelled) {
return cancelled;
}
if (cause.getCause() == cause) {
break;
}
}
return null;
}
private static String abbreviate(String text) {
String flat = text.replaceAll("\\s+", " ");
return flat.length() <= 160 ? flat : flat.substring(0, 157) + "...";
}
}
@@ -0,0 +1,505 @@
package dev.photosync.core.provider.google;
import dev.photosync.core.provider.Album;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.AssetKind;
import dev.photosync.core.provider.BucketPage;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderIdentity;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.provider.TimelineBucket;
import dev.photosync.core.provider.TransferProgress;
import dev.photosync.core.provider.UploadReceipt;
import dev.photosync.core.provider.UploadRequest;
import lombok.extern.slf4j.Slf4j;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* Google Photos, expressed in PhotoSync's terms.
*
* <p>Three things about this API shape everything below, and all three are
* worth knowing before reading further.
*
* <p><b>It only ever shows you what this mod uploaded.</b> Google withdrew the
* broad library scopes in March 2025. What remains is "add media" and "read
* back the media you added", so the Browse tab is a view of PhotoSync's own
* uploads -- not of the player's Google Photos account. That is a permission
* boundary, not a setting.
*
* <p><b>There is no bucket endpoint.</b> Immich hands over every month and its
* count in one cheap call, which is what makes its timeline a true virtual
* list. Google has nothing of the kind, so {@link #timeline} pages through the
* media items itself and groups them into months, then keeps the result so
* {@link #page} is free. The cost is bounded by {@link #MAX_SCAN_PAGES}; past
* that the timeline is truncated and says so in the log.
*
* <p><b>Image URLs expire.</b> Nothing here is a stable image address. Each
* media item carries a {@code baseUrl} that is good for about an hour and has
* the wanted dimensions appended to it, so this class caches those URLs and
* refetches the item when one stops working.
*/
@Slf4j
public final class GooglePhotosProvider implements PhotoProvider {
/** Google's maximum for both listing endpoints. Fewer pages is fewer round trips. */
private static final int SCAN_PAGE_SIZE = 100;
/** A ceiling on what one Browse tab costs: 50 pages of 100 is 5000 items. */
private static final int MAX_SCAN_PAGES = 50;
/** How long a scan is reused before the timeline is walked again. */
private static final Duration SCAN_LIFETIME = Duration.ofMinutes(2);
/**
* How long a {@code baseUrl} is trusted. Google documents roughly 60 minutes;
* this leaves a margin so a URL does not expire between being handed out and
* being fetched.
*/
private static final Duration BASE_URL_LIFETIME = Duration.ofMinutes(45);
/** The longest side to ask for, per {@link ThumbnailSize}. */
private static final int GRID_PIXELS = 512;
private static final int DETAIL_PIXELS = 2048;
/** Google rejects a longer one, and the queue would rather truncate than fail. */
private static final int MAX_DESCRIPTION = 1000;
private final ProviderDescriptor descriptor;
private final GoogleAuth auth;
private final GooglePhotosApi api;
/** {@code baseUrl}s learned from scans and item fetches, by media item id. */
private final Map<String, CachedUrl> baseUrls = new ConcurrentHashMap<>();
private final Object scanLock = new Object();
private Scan scan;
GooglePhotosProvider(ProviderDescriptor descriptor,
ProviderConnection connection,
Supplier<Path> tokenFile,
SignInPrompt prompt) {
this.descriptor = descriptor;
this.auth = new GoogleAuth(connection, tokenFile, prompt);
this.api = new GooglePhotosApi(auth);
}
@Override
public ProviderDescriptor descriptor() {
return descriptor;
}
/**
* Signs in if that has not happened yet, then names the account.
*
* <p>This is the one method allowed to open a browser, and it is reached
* only from the "Test connection" button -- so consent appears because the
* player just asked for it, never in the middle of a game. Everything else
* goes through {@link GoogleAuth#bearer()}, which fails rather than prompts.
*/
@Override
public ProviderIdentity identify() throws ProviderException {
String account = auth.signIn();
return new ProviderIdentity("Google Photos Library API v1",
account.isEmpty() ? "signed in" : account);
}
/**
* Albums this mod created.
*
* <p>{@code excludeNonAppCreatedData} is not a filter for tidiness: the
* appendonly scope cannot add media to an album it did not create, so
* offering the player's other albums would be offering uploads that are
* going to be refused.
*/
@Override
public List<Album> albums() throws ProviderException {
List<Album> albums = new ArrayList<>();
String pageToken = null;
int pages = 0;
do {
Map<String, String> query = new LinkedHashMap<>();
query.put("pageSize", "50");
query.put("excludeNonAppCreatedData", "true");
if (pageToken != null) {
query.put("pageToken", pageToken);
}
GoogleDtos.AlbumsResponse response =
api.get("/v1/albums", query, GoogleDtos.AlbumsResponse.class);
if (response.albums() != null) {
for (GoogleDtos.AlbumSummary summary : response.albums()) {
if (summary.id() == null) {
continue;
}
albums.add(new Album(
summary.id(),
Optional.ofNullable(summary.title()).orElse(summary.id()),
(int) Math.min(Integer.MAX_VALUE, number(summary.mediaItemsCount(), 0)),
Optional.ofNullable(summary.coverPhotoMediaItemId())));
}
}
pageToken = response.nextPageToken();
pages++;
} while (pageToken != null && !pageToken.isBlank() && pages < MAX_SCAN_PAGES);
return albums.stream()
.sorted(Comparator.comparing(Album::name, String.CASE_INSENSITIVE_ORDER))
.toList();
}
@Override
public Album createAlbum(String name) throws ProviderException {
GoogleDtos.AlbumSummary created = api.post("/v1/albums",
new GoogleDtos.CreateAlbumRequest(new GoogleDtos.NewAlbum(name)),
GoogleDtos.AlbumSummary.class);
if (created.id() == null) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google Photos created an album without an id");
}
return new Album(created.id(), name, 0, Optional.empty());
}
/**
* Uploads in Google's two steps: the bytes go to {@code /v1/uploads}, which
* answers with a token, and the token becomes a real media item through
* {@code mediaItems:batchCreate}.
*
* <p><b>The idempotency the interface asks for cannot be delivered here.</b>
* {@link PhotoProvider#upload} requires that re-sending bytes the backend
* already holds come back as {@link UploadReceipt.Outcome#DUPLICATE}, and
* Google's API has no such concept: there is no checksum header, no
* duplicate status, and no way to ask whether an item exists. (The Google
* Photos app deduplicates its own backups; the API does not expose it.) So
* every upload that succeeds is reported as {@code CREATED}, and a job the
* queue retries because the first response was lost will leave two copies in
* the account. Nothing in this class can prevent that -- it is worth knowing
* rather than working around.
*/
@Override
public UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException {
Path file = request.file();
if (!Files.isReadable(file)) {
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file);
}
String uploadToken = api.upload(file, mediaTypeOf(request.fileName()), progress);
String description = request.description().length() > MAX_DESCRIPTION
? request.description().substring(0, MAX_DESCRIPTION)
: request.description();
GoogleDtos.BatchCreateRequest body = new GoogleDtos.BatchCreateRequest(
request.album().id().orElse(null),
List.of(new GoogleDtos.NewMediaItem(
description.isBlank() ? null : description,
new GoogleDtos.SimpleMediaItem(uploadToken, request.fileName()))));
GoogleDtos.BatchCreateResponse response =
api.post("/v1/mediaItems:batchCreate", body, GoogleDtos.BatchCreateResponse.class);
List<GoogleDtos.NewMediaItemResult> results = response.newMediaItemResults();
if (results == null || results.isEmpty()) {
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google Photos accepted the upload but said nothing about what it did with it");
}
GoogleDtos.NewMediaItemResult result = results.get(0);
// batchCreate answers 200 even when the item inside it failed; the
// per-item status is the real outcome. Code 0 is OK in Google's status
// model, and an absent status means the same.
int code = result.status() == null || result.status().code() == null ? 0 : result.status().code();
if (code != 0 || result.mediaItem() == null || result.mediaItem().id() == null) {
String detail = result.status() == null || result.status().message() == null
? "no reason given"
: result.status().message();
throw new ProviderException(ProviderException.Kind.PROTOCOL,
"Google Photos would not store the upload: " + detail);
}
remember(result.mediaItem());
// Always CREATED -- see the note above. Reporting DUPLICATE would be a lie.
return new UploadReceipt(result.mediaItem().id(), UploadReceipt.Outcome.CREATED);
}
@Override
public List<TimelineBucket> timeline(AlbumRef album) throws ProviderException {
return scanFor(album).buckets();
}
@Override
public BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException {
List<RemoteAsset> assets = scanFor(album).pages().get(bucket.key());
return new BucketPage(bucket, assets == null ? List.of() : assets);
}
/**
* Image bytes at roughly the requested size.
*
* <p>Google serves these from a {@code baseUrl} with the wanted box appended
* -- {@code =w512-h512} means "fit inside 512 by 512" -- and always as JPEG,
* which is what the game's decoder wants. Videos have no separate thumbnail
* endpoint; the same URL without a {@code =dv} suffix returns a still frame,
* so a video tile needs no special case.
*
* <p>A {@code baseUrl} stops working roughly an hour after it was issued,
* and a player can leave the Browse tab open longer than that. So an expiry
* is not an error: the URL is dropped, the media item is fetched again for a
* fresh one, and the request is made once more.
*/
@Override
public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
int pixels = size == ThumbnailSize.DETAIL ? DETAIL_PIXELS : GRID_PIXELS;
String url = baseUrlOf(assetId, false);
try {
return api.fetch(sized(url, pixels));
} catch (ProviderException e) {
boolean stale = e.kind() == ProviderException.Kind.NOT_FOUND
|| e.kind() == ProviderException.Kind.AUTHENTICATION;
if (!stale) {
throw e;
}
log.debug("The image URL for {} has expired; asking Google for a new one", assetId);
return api.fetch(sized(baseUrlOf(assetId, true), pixels));
}
}
@Override
public void close() {
// Nothing to release: java.net.http.HttpClient has no close() before
// Java 21, and this module compiles to 17. It uses daemon threads and is
// collected with the provider.
}
// -----------------------------------------------------------------------
// The synthesised timeline
// -----------------------------------------------------------------------
/**
* One walk of the media items, grouped into months.
*
* <p>Held whole rather than per month because that is how it was fetched:
* Google's paging has no way to ask for "June", so once the pages have been
* read there is no reason to make {@link #page} pay for them again.
*/
private record Scan(AlbumRef album,
Instant takenAt,
List<TimelineBucket> buckets,
Map<String, List<RemoteAsset>> pages) {
}
private Scan scanFor(AlbumRef album) throws ProviderException {
synchronized (scanLock) {
Scan current = scan;
boolean usable = current != null
&& current.album().equals(album)
&& Instant.now().isBefore(current.takenAt().plus(SCAN_LIFETIME));
if (usable) {
return current;
}
Scan fresh = rescan(album);
scan = fresh;
return fresh;
}
}
private Scan rescan(AlbumRef album) throws ProviderException {
List<GoogleDtos.MediaItem> items = allItems(album);
Map<String, List<RemoteAsset>> pages = new LinkedHashMap<>();
for (GoogleDtos.MediaItem item : items) {
RemoteAsset asset = assetOf(item);
if (asset == null) {
continue;
}
remember(item);
pages.computeIfAbsent(monthKey(asset.localDay()), key -> new ArrayList<>()).add(asset);
}
List<TimelineBucket> buckets = new ArrayList<>(pages.size());
for (Map.Entry<String, List<RemoteAsset>> entry : pages.entrySet()) {
entry.getValue().sort(Comparator.comparing(RemoteAsset::localCapturedAt).reversed());
buckets.add(new TimelineBucket(entry.getKey(), LocalDate.parse(entry.getKey() + "-01"),
entry.getValue().size()));
}
buckets.sort(Comparator.naturalOrder());
log.debug("Google Photos timeline: {} items in {} months", items.size(), buckets.size());
return new Scan(album, Instant.now(), List.copyOf(buckets), pages);
}
/**
* Every media item in scope, up to the scan ceiling.
*
* <p>Note the asymmetry in what truncation costs. The library listing comes
* back newest first, so a cut-off drops the oldest items -- the ones furthest
* down the timeline. An album search comes back in album order, so a cut-off
* there drops the newest. Both are logged; only the second is likely to be
* noticed, and only by someone with more than {@link #MAX_SCAN_PAGES} pages
* of screenshots in one album.
*/
private List<GoogleDtos.MediaItem> allItems(AlbumRef album) throws ProviderException {
List<GoogleDtos.MediaItem> items = new ArrayList<>();
String pageToken = null;
int pages = 0;
do {
GoogleDtos.MediaItemsResponse response;
if (album.isLibrary()) {
Map<String, String> query = new LinkedHashMap<>();
query.put("pageSize", Integer.toString(SCAN_PAGE_SIZE));
if (pageToken != null) {
query.put("pageToken", pageToken);
}
response = api.get("/v1/mediaItems", query, GoogleDtos.MediaItemsResponse.class);
} else {
response = api.post("/v1/mediaItems:search",
new GoogleDtos.SearchRequest(album.id().orElseThrow(), SCAN_PAGE_SIZE, pageToken),
GoogleDtos.MediaItemsResponse.class);
}
if (response.mediaItems() != null) {
items.addAll(response.mediaItems());
}
pageToken = response.nextPageToken();
pages++;
} while (pageToken != null && !pageToken.isBlank() && pages < MAX_SCAN_PAGES);
if (pageToken != null && !pageToken.isBlank()) {
log.info("Showing the first {} Google Photos items; there are more than PhotoSync browses in one go",
items.size());
}
return items;
}
private static RemoteAsset assetOf(GoogleDtos.MediaItem item) {
if (item.id() == null || item.mediaMetadata() == null) {
return null;
}
Instant capturedAt = instantOf(item.mediaMetadata().creationTime());
if (capturedAt == null) {
// Without a timestamp the asset has no day to live under.
return null;
}
long width = number(item.mediaMetadata().width(), 0);
long height = number(item.mediaMetadata().height(), 0);
boolean video = item.mimeType() != null && item.mimeType().startsWith("video/");
return new RemoteAsset(
item.id(),
video ? AssetKind.VIDEO : AssetKind.IMAGE,
capturedAt,
localTimeOf(capturedAt),
height > 0 ? (double) width / height : 1.0,
// Google has no ThumbHash equivalent, and computing one would
// mean downloading every image first -- exactly what the blurred
// placeholder exists to avoid. Tiles fill in as they load.
Optional.empty(),
Duration.ZERO);
}
/**
* The capture instant read in the viewer's own time zone.
*
* <p>{@link RemoteAsset} wants the wall clock where the photo was taken, and
* Google -- unlike Immich -- returns no UTC offset to reconstruct it with.
* The player's zone is the closest available answer and is the right one in
* the ordinary case, where the screenshots were taken on this machine.
* Someone browsing from another continent will see days grouped by the
* calendar they are reading them on.
*/
private static LocalDateTime localTimeOf(Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
private static String monthKey(LocalDate day) {
return String.format(Locale.ROOT, "%04d-%02d", day.getYear(), day.getMonthValue());
}
// -----------------------------------------------------------------------
// Image URLs
// -----------------------------------------------------------------------
private record CachedUrl(String url, Instant fetchedAt) {
}
private void remember(GoogleDtos.MediaItem item) {
if (item.id() != null && item.baseUrl() != null && !item.baseUrl().isBlank()) {
baseUrls.put(item.id(), new CachedUrl(item.baseUrl(), Instant.now()));
}
}
private String baseUrlOf(String assetId, boolean force) throws ProviderException {
if (!force) {
CachedUrl cached = baseUrls.get(assetId);
if (cached != null && Instant.now().isBefore(cached.fetchedAt().plus(BASE_URL_LIFETIME))) {
return cached.url();
}
}
GoogleDtos.MediaItem item =
api.get("/v1/mediaItems/" + assetId, Map.of(), GoogleDtos.MediaItem.class);
if (item.baseUrl() == null || item.baseUrl().isBlank()) {
throw new ProviderException(ProviderException.Kind.NOT_FOUND,
"Google Photos has no image for " + assetId);
}
remember(item);
return item.baseUrl();
}
/** Google's sizing suffix: fit the image inside a box this many pixels on a side. */
private static String sized(String baseUrl, int pixels) {
return baseUrl + "=w" + pixels + "-h" + pixels;
}
// -----------------------------------------------------------------------
private static Instant instantOf(String rfc3339) {
if (rfc3339 == null) {
return null;
}
try {
return Instant.parse(rfc3339);
} catch (DateTimeParseException e) {
log.debug("Ignoring a Google Photos item with an unreadable creation time: {}", rfc3339);
return null;
}
}
/** Google JSON-encodes its 64-bit integers as strings; this is the way back. */
private static long number(String value, long fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static String mediaTypeOf(String fileName) {
String lower = fileName.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) {
return "image/png";
}
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg";
}
if (lower.endsWith(".webp")) {
return "image/webp";
}
return "application/octet-stream";
}
}
@@ -0,0 +1,54 @@
package dev.photosync.core.provider.google;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderFactory;
import dev.photosync.core.provider.ProviderId;
import java.nio.file.Path;
import java.util.function.Supplier;
/**
* Registers Google Photos with the {@code ProviderCatalog}.
*
* <p>Two things have to be handed in that Immich's factory does not need, and
* both are consequences of OAuth. The sign-in produces a refresh token that has
* to outlive the session, so this needs to know where to keep it; and getting
* that token means opening a browser, which core cannot do, so the caller
* supplies the way to do it. Both arrive as functions rather than values
* because the settings directory can move while the game is running.
*/
public final class GooglePhotosProviderFactory implements ProviderFactory {
/**
* Where the refresh token lives, next to the settings file.
*
* <p>Kept out of {@code photosync.json} deliberately. That file is the one
* players paste into issue reports, and a refresh token in it is a
* standing invitation to somebody else's photo library. This one is written
* through {@code JsonFile}, which restricts it to the owning user.
*/
public static final String TOKEN_FILE = "google-photos.json";
private final ProviderDescriptor descriptor =
new ProviderDescriptor(new ProviderId("googlephotos"), "photosync.provider.googlephotos", true);
private final Supplier<Path> tokenFile;
private final SignInPrompt prompt;
public GooglePhotosProviderFactory(Supplier<Path> tokenFile, SignInPrompt prompt) {
this.tokenFile = tokenFile;
this.prompt = prompt;
}
@Override
public ProviderDescriptor descriptor() {
return descriptor;
}
@Override
public PhotoProvider connect(ProviderConnection connection) {
return new GooglePhotosProvider(descriptor, connection, tokenFile, prompt);
}
}
@@ -0,0 +1,149 @@
package dev.photosync.core.provider.google;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* The other half of Google's redirect: a socket on the loopback interface that
* waits for the browser to come back with an authorization code.
*
* <p>A raw {@link ServerSocket} rather than {@code com.sun.net.httpserver}.
* Minecraft ships its own trimmed Java runtime, {@code jdk.httpserver} is not a
* module we can prove is in it on every launcher, and a sign-in that throws
* {@code NoClassDefFoundError} on somebody else's machine is not worth the
* convenience. One request line and one fixed response is all this needs.
*
* <p>Google allows a desktop client to redirect to {@code 127.0.0.1} on any
* port without registering it, which is why the port can be whatever the OS
* hands out.
*/
@Slf4j
final class LoopbackReceiver implements AutoCloseable {
/** Long enough for a browser that is still starting up, short enough not to hang a worker. */
private static final int READ_TIMEOUT_MILLIS = 10_000;
private final ServerSocket socket;
LoopbackReceiver() throws IOException {
// Loopback only, and a backlog of one: this listens for a single
// redirect from the player's own browser and nothing else.
this.socket = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
}
/** What to hand Google as {@code redirect_uri}. It must match at token exchange, byte for byte. */
String redirectUri() {
return "http://127.0.0.1:" + socket.getLocalPort();
}
/**
* Blocks until the browser arrives with a query string, and answers it with
* a page telling the player to go back to the game.
*
* <p>Browsers open speculative connections and ask for {@code favicon.ico}
* unprompted, so anything without a {@code code} or {@code error} is turned
* away and the wait continues until the deadline.
*/
Map<String, String> awaitRedirect(Duration timeout) throws IOException {
long deadline = System.currentTimeMillis() + timeout.toMillis();
while (true) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
throw new SocketTimeoutException("Nothing came back from the browser");
}
socket.setSoTimeout((int) Math.min(remaining, Integer.MAX_VALUE));
try (Socket client = socket.accept()) {
client.setSoTimeout(READ_TIMEOUT_MILLIS);
Map<String, String> parameters = read(client);
if (parameters.containsKey("code") || parameters.containsKey("error")) {
respond(client, parameters.containsKey("code"));
return parameters;
}
respond(client, false);
} catch (SocketTimeoutException e) {
throw e;
} catch (IOException e) {
// One browser connection going wrong is not the sign-in going
// wrong; the real one may still be on its way.
log.debug("Ignoring a loopback connection that failed: {}", e.toString());
}
}
}
private Map<String, String> read(Socket client) throws IOException {
BufferedReader reader = new BufferedReader(
new InputStreamReader(client.getInputStream(), StandardCharsets.ISO_8859_1));
String requestLine = reader.readLine();
if (requestLine == null) {
return Map.of();
}
String[] parts = requestLine.split(" ");
int query = parts.length < 2 ? -1 : parts[1].indexOf('?');
return query < 0 ? Map.of() : parse(parts[1].substring(query + 1));
}
private static Map<String, String> parse(String query) {
Map<String, String> parameters = new LinkedHashMap<>();
for (String pair : query.split("&")) {
if (pair.isEmpty()) {
continue;
}
int equals = pair.indexOf('=');
String name = equals < 0 ? pair : pair.substring(0, equals);
String value = equals < 0 ? "" : pair.substring(equals + 1);
parameters.put(decode(name), decode(value));
}
return parameters;
}
private static String decode(String value) {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
return value;
}
}
/**
* The page the player is left looking at. Deliberately plain text in a
* minimal document: it has to render from a socket that is about to close,
* with no stylesheet and no second request.
*/
private static void respond(Socket client, boolean signedIn) throws IOException {
String message = signedIn
? "PhotoSync is signed in. You can close this tab and go back to Minecraft."
: "PhotoSync is still waiting for Google's answer. You can close this tab.";
byte[] page = ("<!doctype html><meta charset=\"utf-8\"><title>PhotoSync</title>"
+ "<body style=\"font-family:sans-serif;margin:3rem\"><p>" + message + "</p>")
.getBytes(StandardCharsets.UTF_8);
OutputStream out = client.getOutputStream();
out.write(("HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/html; charset=utf-8\r\n"
+ "Content-Length: " + page.length + "\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
out.write(page);
out.flush();
}
@Override
public void close() {
try {
socket.close();
} catch (IOException e) {
log.debug("Closing the sign-in socket threw", e);
}
}
}
@@ -0,0 +1,31 @@
package dev.photosync.core.provider.google;
import org.slf4j.LoggerFactory;
/**
* How the player is put in front of Google's consent page.
*
* <p>Google Photos has no API key to paste: the only way to get a token is to
* send the player to Google in a browser and catch what comes back. Core cannot
* open a browser -- that is a Minecraft capability -- so the client module hands
* one of these in, and this package stays free of the game.
*/
@FunctionalInterface
public interface SignInPrompt {
/**
* Shows {@code url} to the player, ideally by opening their browser.
*
* <p>Called from a worker thread, once per sign-in. It does not have to
* succeed: the URL is logged as well, and the sign-in waits for the
* redirect either way, so a player whose browser will not open can still
* paste the link themselves.
*/
void show(String url);
/** The fallback when nobody supplied one: the log is the only way in. */
static SignInPrompt logging() {
return url -> LoggerFactory.getLogger(SignInPrompt.class)
.info("Open this page to finish signing in to Google Photos: {}", url);
}
}
@@ -0,0 +1,62 @@
package dev.photosync.core.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** The marker that decides where the settings file lives. */
class ConfigLocationsTest {
@TempDir
Path temp;
@Test
void resolvesTheInstanceDirectoryWhenThereIsNoMarker() {
ConfigLocations locations = new ConfigLocations(temp.resolve("instance"), temp.resolve("user"));
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
assertEquals(temp.resolve("instance"), locations.directory(ConfigLocation.INSTANCE));
assertEquals(temp.resolve("user"), locations.directory(ConfigLocation.USER));
}
@Test
void remembersTheChoiceAcrossInstances() throws Exception {
Path instance = temp.resolve("instance");
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
locations.writeMarker(ConfigLocation.USER);
// A fresh PhotoSync reads the same marker and arrives at the same answer.
ConfigLocations reopened = new ConfigLocations(instance, temp.resolve("user"));
assertEquals(ConfigLocation.USER, reopened.resolve());
}
@Test
void fallsBackToTheInstanceDirectoryForAGarbageMarker() throws Exception {
Path instance = temp.resolve("instance");
Files.createDirectories(instance);
Files.writeString(instance.resolve("location.json"), "not json");
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
}
@Test
void fallsBackToTheInstanceDirectoryForAnUnknownLocation() throws Exception {
Path instance = temp.resolve("instance");
Files.createDirectories(instance);
Files.writeString(instance.resolve("location.json"), "{\"location\":\"elsewhere\"}");
ConfigLocations locations = new ConfigLocations(instance, temp.resolve("user"));
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
}
@Test
void fixedCatalogTreatsTheUserDirectoryAsTheInstanceDirectory() {
ConfigLocations locations = ConfigLocations.fixed(temp.resolve("dir"));
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
assertEquals(locations.directory(ConfigLocation.INSTANCE), locations.directory(ConfigLocation.USER));
}
}
@@ -0,0 +1,105 @@
package dev.photosync.core.config;
import dev.photosync.core.PhotoSync;
import dev.photosync.core.capture.CaptureOrigin;
import dev.photosync.core.capture.CapturedScreenshot;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.immich.ImmichProviderFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Switching the storage location really moves the settings file, and nothing else. */
class PhotoSyncRelocationTest {
@TempDir
Path temp;
private ConfigLocations locations() {
return new ConfigLocations(temp.resolve("instance"), temp.resolve("user"));
}
private PhotoSync started(ConfigLocations locations) {
PhotoSync core = new PhotoSync(locations, List.of(new ImmichProviderFactory()));
core.start();
return core;
}
@Test
void movesSettingsFileAndRemembersTheChoice() throws Exception {
Path instance = temp.resolve("instance");
Path user = temp.resolve("user");
ConfigLocations locations = locations();
PhotoSync core = started(locations);
core.config().update(current -> current.toBuilder().albumId("first").build());
assertTrue(Files.exists(instance.resolve("photosync.json")));
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
// The file followed the setting, and the marker says where it went.
assertFalse(Files.exists(instance.resolve("photosync.json")));
assertTrue(Files.exists(user.resolve("photosync.json")));
assertEquals(ConfigLocation.USER, locations.resolve());
assertEquals(ConfigLocation.USER, core.config().current().location());
assertEquals(user, core.dataDirectory());
// Later saves land in the new place.
core.config().update(current -> current.toBuilder().albumId("second").build());
assertTrue(Files.readString(user.resolve("photosync.json")).contains("\"second\""));
core.close();
// A fresh PhotoSync resolves the marker and reads the moved file.
PhotoSync reopened = started(locations);
assertEquals(ConfigLocation.USER, reopened.config().current().location());
assertEquals("second", reopened.config().current().albumId());
reopened.close();
}
@Test
void switchesBackToTheInstanceDirectory() throws Exception {
Path instance = temp.resolve("instance");
Path user = temp.resolve("user");
ConfigLocations locations = locations();
PhotoSync core = started(locations);
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
assertTrue(Files.exists(user.resolve("photosync.json")));
core.config().update(current -> current.toBuilder().location(ConfigLocation.INSTANCE).build());
assertTrue(Files.exists(instance.resolve("photosync.json")));
assertFalse(Files.exists(user.resolve("photosync.json")));
assertEquals(ConfigLocation.INSTANCE, locations.resolve());
assertEquals(instance, core.dataDirectory());
core.close();
}
@Test
void leavesTheUploadQueueInTheInstanceDirectory() throws Exception {
Path instance = temp.resolve("instance");
Path user = temp.resolve("user");
Path shot = temp.resolve("screenshot.png");
Files.writeString(shot, "fake png");
ConfigLocations locations = locations();
PhotoSync core = started(locations);
core.queue().enqueue(CapturedScreenshot.of(shot, CaptureOrigin.MANUAL), AlbumRef.library(), "test");
assertTrue(Files.exists(instance.resolve("uploads.json")));
core.config().update(current -> current.toBuilder().location(ConfigLocation.USER).build());
// The queue references instance-local screenshot paths, so it stays put.
assertTrue(Files.exists(instance.resolve("uploads.json")));
assertFalse(Files.exists(user.resolve("uploads.json")));
core.close();
}
}
@@ -26,9 +26,23 @@ public interface GameContext {
*/
Optional<CameraPose> cameraPose();
/** {@code .minecraft/config}, where PhotoSync keeps its settings and upload queue. */
/**
* The game-instance config directory (the {@code config/photosync} folder
* inside the game directory), where PhotoSync historically keeps its
* settings and upload queue. The settings file can be moved to
* {@link #userDataDirectory()} from the settings screen; the upload queue
* always stays here.
*/
Path configDirectory();
/**
* The OS-user-level directory offered as the alternative home for the
* settings file: {@code ~/.config/photosyncmod} on Linux and macOS,
* {@code %APPDATA%\photosyncmod} on Windows. Only used once the player
* chooses it; {@link #configDirectory()} stays the default.
*/
Path userDataDirectory();
/**
* Runs a task on the render thread, or immediately if already on it.
*
@@ -47,6 +61,22 @@ public interface GameContext {
*/
void reveal(Path path);
/**
* Opens a URL in the player's browser.
*
* <p>Here for the Google Photos sign-in, which cannot happen inside the
* game: the consent page is Google's, it is where the password is typed,
* and re-hosting it in a Minecraft screen would be both impossible and the
* wrong thing to teach a player to trust.
*
* <p>It crosses this bridge rather than calling {@code java.awt.Desktop}
* directly because AWT and GLFW cannot both own the main thread on macOS,
* where the game is launched with {@code -XstartOnFirstThread}. Minecraft
* ships an opener that already accounts for that, and it is the same one
* behind every link in the game's own menus.
*/
void openUrl(String url);
/** The running Minecraft version, for logs and the settings screen's footer. */
String minecraftVersion();
@@ -37,6 +37,26 @@ import java.util.concurrent.CompletableFuture;
@Slf4j
public final class ThumbnailCache implements AutoCloseable {
/**
* How many times one asset's thumbnail is fetched before the tile settles
* for its blur.
*
* <p>The first attempt fails for all the ordinary reasons -- the server was
* restarting, the connection dropped, the loader handed back a truncated
* body -- and giving up on it permanently leaves a tile that is blurred for
* the rest of the session with nothing to say why. Three attempts covers
* the transient cases; past that the failure is real and is drawn as one.
*/
private static final int MAX_ATTEMPTS = 3;
/**
* Frames to wait before trying again, multiplied by the attempt number.
* Long enough that a server that is down is not hammered by every tile on
* screen, short enough that the grid repairs itself while the player is
* still looking at it.
*/
private static final int RETRY_FRAMES = 40;
/** What to draw for one asset, and whether it is the real thing yet. */
public record Thumbnail(TextureHandle texture, boolean placeholder) {
}
@@ -89,10 +109,25 @@ public final class ThumbnailCache implements AutoCloseable {
return entry.thumbnail();
}
/** Whether this asset's thumbnail failed outright, so the grid can mark it. */
/**
* Starts an asset's fetch without drawing it, for a tile that is about to
* scroll into view.
*
* <p>Refuses once the cache is full, so lookahead can never evict a tile the
* player is actually looking at: the visible tiles are asked for first, and
* whatever room is left over is what the band gets.
*/
public void prefetch(RemoteAsset asset) {
if (!entries.containsKey(asset.id()) && entries.size() >= capacity) {
return;
}
of(asset);
}
/** Whether this asset's thumbnail failed for good, so the grid can mark it. */
public boolean failed(String assetId) {
Entry entry = entries.get(assetId);
return entry != null && entry.failed;
return entry != null && entry.exhausted();
}
/** Call at the end of a frame, once every visible tile has been asked for. */
@@ -132,7 +167,8 @@ public final class ThumbnailCache implements AutoCloseable {
private TextureHandle texture;
private boolean real;
private boolean failed;
private int attempts;
private long retryFrame;
private CompletableFuture<byte[]> pending;
private long touched;
@@ -145,12 +181,17 @@ public final class ThumbnailCache implements AutoCloseable {
return texture == null ? Optional.empty() : Optional.of(new Thumbnail(texture, !real));
}
/** Out of attempts: whatever is drawn now is what this tile gets. */
private boolean exhausted() {
return !real && attempts >= MAX_ATTEMPTS;
}
/**
* Advances this entry by whatever is available without blocking: start a
* request, or take delivery of one.
*/
private void poll() {
if (real || failed) {
if (real || exhausted() || frame < retryFrame) {
return;
}
if (pending == null) {
@@ -168,8 +209,10 @@ public final class ThumbnailCache implements AutoCloseable {
adopt(textures.decode(finished.join()));
real = true;
} catch (IOException | RuntimeException e) {
log.debug("Thumbnail {} is not drawable: {}", asset.id(), e.toString());
failed = true;
attempts++;
retryFrame = frame + (long) RETRY_FRAMES * attempts;
log.debug("Thumbnail {} is not drawable (attempt {} of {}): {}",
asset.id(), attempts, MAX_ATTEMPTS, e.toString());
}
}
@@ -42,6 +42,23 @@ public final class QueueScreen extends PhotoSyncScreen {
private static final int DETAIL_WIDTH = 140;
private static final int MIN_WIDTH_FOR_DETAIL = 340;
/**
* How many times a screenshot is read off disk before the pane gives up.
*
* <p>One attempt is not enough, and the reason is specific to what this list
* holds. A row can appear the instant a capture is queued, while the PNG
* behind it is still being flushed by the IO pool; the screenshots folder is
* on a network drive or a spinning disk often enough; and an antivirus that
* has the file open will refuse one read and allow the next. Failing those
* permanently leaves the newest screenshot -- the one at the top of the
* list, the one the player actually clicks -- showing "unavailable" for the
* rest of the session, with no way to ask again short of changing tabs.
*/
private static final int PREVIEW_ATTEMPTS = 3;
/** Milliseconds before the next attempt, multiplied by the attempt number. */
private static final long PREVIEW_RETRY_MILLIS = 600;
private final UploadQueue queue;
private final ScrollModel scroll;
private final Preview preview = new Preview();
@@ -223,7 +240,7 @@ public final class QueueScreen extends PhotoSyncScreen {
return;
}
UploadJob job = selection.get().job();
preview.follow(job.path());
preview.follow(job.path(), System.currentTimeMillis());
Rect inner = detailArea.inset(4);
int line = render.lineHeight() + 2;
@@ -366,20 +383,38 @@ public final class QueueScreen extends PhotoSyncScreen {
private Path source;
private CompletableFuture<byte[]> reading;
private TextureHandle texture;
private boolean failed;
private int attempts;
private long retryAtMillis;
/** Called every frame with the selected file; only acts when it changes. */
private void follow(Path path) {
private void follow(Path path, long now) {
if (!path.equals(source)) {
close();
source = path;
reading = CompletableFuture.supplyAsync(() -> readAll(path));
}
poll();
poll(now);
}
private void poll() {
if (reading == null || !reading.isDone()) {
/**
* Advances by whatever is available without blocking: start a read, or
* take delivery of one.
*
* <p>The read is started here rather than in {@link #follow} so that a
* retry needs no separate path -- an attempt that failed simply leaves
* nothing pending, and the next frame past the backoff starts another.
*/
private void poll(long now) {
if (texture != null || source == null) {
return;
}
if (reading == null) {
if (attempts < PREVIEW_ATTEMPTS && now >= retryAtMillis) {
Path path = source;
reading = CompletableFuture.supplyAsync(() -> readAll(path));
}
return;
}
if (!reading.isDone()) {
return;
}
CompletableFuture<byte[]> finished = reading;
@@ -387,8 +422,10 @@ public final class QueueScreen extends PhotoSyncScreen {
try {
texture = ui.bridge().textures().decode(finished.join());
} catch (IOException | RuntimeException e) {
log.debug("Cannot preview {}", source, e);
failed = true;
attempts++;
retryAtMillis = now + PREVIEW_RETRY_MILLIS * attempts;
log.debug("Cannot preview {} (attempt {} of {}): {}",
source, attempts, PREVIEW_ATTEMPTS, e.toString());
}
}
@@ -396,8 +433,9 @@ public final class QueueScreen extends PhotoSyncScreen {
return Optional.ofNullable(texture);
}
/** Out of attempts: this screenshot is not going to draw. */
private boolean failed() {
return failed;
return texture == null && attempts >= PREVIEW_ATTEMPTS;
}
/** Forgets the current image so the next {@link #follow} reloads. */
@@ -416,12 +454,22 @@ public final class QueueScreen extends PhotoSyncScreen {
texture.close();
texture = null;
}
failed = false;
attempts = 0;
retryAtMillis = 0;
}
private byte[] readAll(Path path) {
try {
return Files.readAllBytes(path);
byte[] bytes = Files.readAllBytes(path);
if (bytes.length == 0) {
// The name is claimed by creating the file empty and the
// pixels follow on the IO pool, so a screenshot taken a
// moment ago is briefly a real file with nothing in it.
// Treating that as a decode failure would spend an attempt
// saying "not an image" about a file that is about to be one.
throw new CompletionException(new IOException(path + " has not been written yet"));
}
return bytes;
} catch (IOException e) {
throw new CompletionException(e);
}
@@ -2,6 +2,7 @@ package dev.photosync.ui.screen;
import dev.photosync.core.config.AutoCaptureSettings;
import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.config.ConfigLocation;
import dev.photosync.core.config.NotificationSettings;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.config.UploadSettings;
@@ -26,6 +27,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
@@ -47,6 +49,26 @@ import java.util.function.UnaryOperator;
*/
public final class SettingsScreen extends PhotoSyncScreen {
/**
* A thread of its own for "test connection", rather than the common pool.
*
* <p>The common pool is sized from the core count and is shared with every
* other {@code supplyAsync} in the mod -- the queue screen's previews among
* them. A connection test used to be a request with a timeout on it, which
* that pool could absorb; signing in to Google Photos is a wait for a human
* to find a browser window and press Allow, which it cannot. On a two-core
* machine one such wait is the whole pool.
*
* <p>Daemon, so a sign-in nobody ever finished does not keep the game from
* closing. The button is disabled while a test runs, so this makes at most
* one thread at a time.
*/
private static final Executor TEST_EXECUTOR = task -> {
Thread thread = new Thread(task, "photosync-connection-test");
thread.setDaemon(true);
thread.start();
};
/** One line of the form. A null widget makes it a section heading. */
private static final class Row {
@@ -122,6 +144,7 @@ public final class SettingsScreen extends PhotoSyncScreen {
rows.clear();
measuredLineHeight = -1;
buildStorage();
buildConnection();
buildUpload();
buildAutoCapture();
@@ -129,6 +152,24 @@ public final class SettingsScreen extends PhotoSyncScreen {
buildBrowser();
}
private void buildStorage() {
heading("photosync.settings.section.storage");
Toggle outsideGame = new Toggle(chrome, chrome.translate("photosync.settings.user_config_location"),
() -> draft().location() == ConfigLocation.USER,
value -> edit(config -> config.toBuilder()
.location(value ? ConfigLocation.USER : ConfigLocation.INSTANCE)
.build()));
outsideGame.describedAs(chrome.translate("photosync.settings.user_config_location.detail",
ui.core().locations().directory(ConfigLocation.USER)));
rows.add(new Row("photosync.settings.user_config_location", content.add(outsideGame), true));
control("photosync.settings.config_folder",
new Button(chrome, chrome.translate("photosync.settings.show_folder"), this::revealConfigFolder));
}
private void revealConfigFolder() {
ui.game().reveal(ui.core().dataDirectory());
}
private void buildConnection() {
heading("photosync.settings.section.connection");
@@ -338,7 +379,7 @@ public final class SettingsScreen extends PhotoSyncScreen {
} catch (RuntimeException e) {
testStatus = chrome.translate("photosync.settings.test.failed", e.toString());
}
}).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
}, TEST_EXECUTOR).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
}
// -----------------------------------------------------------------------
@@ -167,6 +167,10 @@ public final class TimelineScreen extends PhotoSyncScreen {
scroll.advance(System.currentTimeMillis());
chrome.well(render, grid);
// The tile cache's frame spans the whole body rather than just the grid,
// because the opened photo borrows a tile texture and would otherwise be
// asking for one that this frame's eviction pass had already dropped.
tiles.beginFrame();
if (renderState(render)) {
renderGrid(render, mouseX, mouseY);
scroll.render(render, mouseX, mouseY);
@@ -174,6 +178,7 @@ public final class TimelineScreen extends PhotoSyncScreen {
if (opened != null) {
renderOpened(render, mouseX, mouseY);
}
tiles.endFrame();
}
/** Draws whatever stands in for the grid, and says whether the grid itself should be drawn. */
@@ -210,15 +215,15 @@ public final class TimelineScreen extends PhotoSyncScreen {
}
private void renderGrid(RenderBridge render, int mouseX, int mouseY) {
tiles.beginFrame();
int offset = scroll.offset();
// One viewport of lookahead, so a month is already being fetched by the
// time the player scrolls it into view.
int prefetchTop = offset - grid.height();
int prefetchBottom = offset + grid.height() * 2;
int first = firstVisible(prefetchTop);
render.pushClip(grid.x(), grid.y(), grid.width(), grid.height());
for (int i = firstVisible(prefetchTop); i < blocks.size(); i++) {
for (int i = first; i < blocks.size(); i++) {
Block block = blocks.get(i);
if (block.top() > prefetchBottom) {
break;
@@ -232,7 +237,45 @@ public final class TimelineScreen extends PhotoSyncScreen {
}
}
render.popClip();
tiles.endFrame();
// Second pass on purpose: every visible tile has now had its turn at the
// loader, so the band gets whatever request slots and cache room are
// left rather than competing for them.
for (int i = first; i < blocks.size(); i++) {
Block block = blocks.get(i);
if (block.top() > prefetchBottom) {
break;
}
prefetchBlock(block, prefetchTop, prefetchBottom);
}
}
/**
* Asks for the thumbnails of the tiles between {@code top} and {@code
* bottom} in content space, without drawing them.
*
* <p>Fetching a thumbnail only once its tile is inside the viewport means
* every tile is blurred for a whole round trip after it appears, which over
* a scroll is most of what the player sees. The months were already being
* loaded a viewport ahead; this gives their images the same head start.
*/
private void prefetchBlock(Block block, int top, int bottom) {
List<RemoteAsset> assets = block.section().assets();
if (assets.isEmpty()) {
return;
}
int stride = block.tileSize() + theme().tileGap();
int gridTop = block.gridTop(headerHeight);
int lastRow = (bottom - gridTop) / stride;
if (lastRow < 0) {
return;
}
int firstRow = Math.max(0, (top - gridTop) / stride);
int from = firstRow * block.columns();
int to = Math.min(assets.size(), (lastRow + 1) * block.columns());
for (int index = from; index < to; index++) {
tiles.prefetch(assets.get(index));
}
}
private void renderBlock(RenderBridge render, Block block, int offset, int mouseX, int mouseY) {
@@ -278,7 +321,10 @@ public final class TimelineScreen extends PhotoSyncScreen {
render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
Optional<ThumbnailCache.Thumbnail> thumbnail = tiles.of(asset);
thumbnail.ifPresent(value -> drawCropped(render, value.texture(), tile));
if (thumbnail.isEmpty() && tiles.failed(asset.id())) {
// Marked even when there is a ThumbHash to draw. A tile that keeps its
// blur because the image never arrived looks exactly like one the mod
// simply chose not to sharpen, and the player has no way to tell.
if (tiles.failed(asset.id())) {
chrome.centered(render, "!", tile, theme().danger());
}
if (asset.isVideo() && ui.core().config().current().browser().showVideoBadge()) {
@@ -302,33 +348,49 @@ public final class TimelineScreen extends PhotoSyncScreen {
/**
* The opened photo, at whatever resolution has arrived so far.
*
* <p>What arrives first is the ThumbHash: a blurred 32x32 that is right for a
* tile and nowhere near enough for a full-screen view. It is still worth
* drawing, because it says which photo is opening, but it is dimmed and
* labelled while it stands in -- an unannotated blur is indistinguishable
* from a mod that fetched the wrong size.
* <p>The stand-in while the full image loads is the grid's own thumbnail:
* it is already on the GPU, it is the picture the player just clicked, and
* it is sharp enough to be worth looking at. Only when the grid has nothing
* real either does this fall back to the ThumbHash -- a blurred 32x32 that
* is right for a tile and nowhere near enough for a full-screen view, so it
* is dimmed and labelled while it stands in. An unannotated blur is
* indistinguishable from a mod that fetched the wrong size.
*/
private void renderOpened(RenderBridge render, int mouseX, int mouseY) {
detail.beginFrame();
Rect area = body();
render.fill(area.x(), area.y(), area.width(), area.height(), theme().overlay());
Rect frame = area.inset(6);
Optional<ThumbnailCache.Thumbnail> image = detail.of(opened);
Optional<ThumbnailCache.Thumbnail> full = detail.of(opened);
// Keeps asking the grid cache too, so the tile survives this frame's
// eviction pass even when the player has scrolled it out of the grid.
Optional<ThumbnailCache.Thumbnail> tile = tiles.of(opened);
if (detail.failed(opened.id())) {
if (full.isPresent() && !full.get().placeholder()) {
drawContained(render, full.get().texture(), frame);
} else if (detail.failed(opened.id())) {
chrome.notice(render, frame, chrome.translate("photosync.browse.open_failed"),
chrome.translate("photosync.browse.open_failed.hint"));
} else if (image.isEmpty()) {
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
} else {
drawContained(render, image.get().texture(), frame);
if (image.get().placeholder()) {
render.fill(frame.x(), frame.y(), frame.width(), frame.height(),
theme().fade(theme().overlay(), 0.6f));
chrome.centered(render, chrome.translate("photosync.browse.opening"),
new Rect(frame.x(), frame.centerY() - render.lineHeight(), frame.width(), render.lineHeight()),
theme().text());
chrome.busyBar(render, new Rect(frame.centerX() - 60, frame.centerY() + 6, 120, 3),
Optional<ThumbnailCache.Thumbnail> standIn =
tile.filter(image -> !image.placeholder()).or(() -> full);
standIn.ifPresent(image -> {
drawContained(render, image.texture(), frame);
if (image.placeholder()) {
render.fill(frame.x(), frame.y(), frame.width(), frame.height(),
theme().fade(theme().overlay(), 0.6f));
chrome.centered(render, chrome.translate("photosync.browse.opening"),
new Rect(frame.x(), frame.centerY() - render.lineHeight(),
frame.width(), render.lineHeight()),
theme().text());
}
});
if (standIn.isEmpty()) {
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
} else {
// Under the picture rather than over it, because the picture is
// now worth seeing: the bar says a sharper one is still coming.
chrome.busyBar(render, new Rect(frame.centerX() - 60, frame.bottom() - 6, 120, 3),
System.currentTimeMillis(), theme().accent());
}
}