add google photos support & fix preview

This commit is contained in:
iceBear67
2026-08-08 10:26:17 +00:00
parent b0a23544e3
commit 0108def92d
26 changed files with 2068 additions and 51 deletions
@@ -8,6 +8,8 @@ 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;
@@ -41,6 +43,7 @@ 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;
@@ -63,7 +66,7 @@ public final class PhotoSync implements AutoCloseable {
/** The standard set of backends, stored in the given directory. */
public PhotoSync(Path directory) {
this(ConfigLocations.fixed(directory), List.of(new ImmichProviderFactory()));
this(ConfigLocations.fixed(directory));
}
/** Takes the backend list explicitly so tests can run against a fake one. */
@@ -73,7 +76,35 @@ public final class PhotoSync implements AutoCloseable {
/** The standard set of backends, with a switchable storage location. */
public PhotoSync(ConfigLocations locations) {
this(locations, List.of(new ImmichProviderFactory()));
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. */
@@ -155,6 +186,10 @@ public final class PhotoSync implements AutoCloseable {
}
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;
@@ -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);
}
}