From 8dfe44b43eff1266bba564019bc6d281ed25c8d7 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Thu, 9 Apr 2026 04:16:12 +0100
Subject: [PATCH 01/25] feat(photos): replace legacy Photos API with Google
Photos Picker API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the deprecated Google Photos Library API (which required the broad
`photoslibrary.readonly` scope) with the new Google Photos Picker API. Users
now explicitly select photos in a first-party Google-hosted popup before any
data is accessed by Nextcloud.
## New OAuth scope
Adds `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` in
place of the old library read scope. Existing users must disconnect and
re-authenticate to obtain this scope.
## Backend (PHP)
### GooglePhotosAPIService (new)
- POST /v1/sessions — create a Picker session and return the hosted picker URI
- GET /v1/sessions/{id} — poll until mediaItemsSet becomes true
- GET /v1/mediaItems?sessionId= — paginate picked items (100 per page)
- DELETE /v1/sessions/{id} — clean up session after import or cancellation
- importFromPickerSession(): paginated download loop capped at 500 MB per run;
persists nextPageToken in user config so large libraries resume across job
runs without re-fetching already-processed pages; updates nb_imported_photos
and nb_photos_seen after each item/page for live frontend progress
- Cross-session deduplication: downloaded item IDs stored in imported_photo_ids
user config key and skipped on re-import; ID list is scoped to target folder
- downloadPickerItem(): full-quality download URL (=d images, =dv video);
deletes orphaned empty file on LockedException or fopen failure; closes
resource before unlocking/deleting on download error
- startImportPhotos(): resets all progress counters including photo_next_page_token
- cancelImport(): removes pending job and deletes active picker session via API
- deletePickerSession(): DELETE /v1/sessions/{id} + clears picker_session_id
### ImportPhotosJob (new background job)
- Extends QueuedJob; runs inside user and filesystem scope
- Timeout guard to prevent overlapping runs
- Downloads up to 500 MB per run then re-queues; sends Nextcloud notification
on completion, then cleans up the picker session
### GoogleAPIController (extended)
- createPickerSession — POST /picker-session
- getPickerSession — GET /picker-session?sessionId=
- deletePickerSession — DELETE /picker-session?sessionId=
- importPhotos — GET /import-photos?sessionId=
- getImportPhotosInformation — GET /import-photos-info
### ConfigController (extended)
- GET /config returns current user config; used by OAuth popup to refresh
parent page without full reload
- setConfig clears user_scopes on disconnect so stale scope flags do not persist
### Notifier (extended)
- import_photos_finished: pluralised notification with item count and target path
### Routes
- POST/GET/DELETE /picker-session
- GET /import-photos, /import-photos-info
## Frontend (Vue)
### AdminSettings.vue
- Updated API list to show "Google Photos Picker API" (not "Photos Library API")
### PersonalSettings.vue
Picker flow (replaces old library-read UI entirely):
1. "Open Google Photos picker" creates a session and immediately opens the
picker in a popup — no second confirmation step
2. setInterval polls GET /picker-session every ~4 s; when mediaItemsSet is
true the import is triggered automatically
3. While picker is open: hint about 2000-photo limit, location data warning,
"auto-import" hint, Open Picker and Cancel buttons
4. Import-in-progress UI: spinner, queued/progress messages, progress bar,
cancel button; progress polled every 5 s
- postMessage origin validated against window.location.origin to prevent
spoofed messages from cross-origin iframes/popups; listener removed after
first valid message to avoid leaks
- onCancelPickerSession calls DELETE /picker-session backend before clearing
local state so the session is cleaned up on Google's side
- Removed unused pickerSessionDone data property and lastPhotoImportDate
computed property
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
appinfo/routes.php | 6 +
lib/BackgroundJob/ImportPhotosJob.php | 39 +++
lib/Controller/ConfigController.php | 32 +-
lib/Controller/GoogleAPIController.php | 95 ++++++
lib/Notification/Notifier.php | 12 +
lib/Service/GooglePhotosAPIService.php | 422 +++++++++++++++++++++++++
lib/Settings/Personal.php | 3 +
src/components/AdminSettings.vue | 2 +-
src/components/PersonalSettings.vue | 330 ++++++++++++++++++-
9 files changed, 936 insertions(+), 5 deletions(-)
create mode 100644 lib/BackgroundJob/ImportPhotosJob.php
create mode 100644 lib/Service/GooglePhotosAPIService.php
diff --git a/appinfo/routes.php b/appinfo/routes.php
index ec357a78..a3cb0a2b 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -12,6 +12,7 @@
return [
'routes' => [
['name' => 'config#oauthRedirect', 'url' => '/oauth-redirect', 'verb' => 'GET'],
+ ['name' => 'config#getConfig', 'url' => '/config', 'verb' => 'GET'],
['name' => 'config#setConfig', 'url' => '/config', 'verb' => 'PUT'],
['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'],
['name' => 'config#getLocalAddressBooks', 'url' => '/local-addressbooks', 'verb' => 'GET'],
@@ -22,6 +23,11 @@
['name' => 'googleAPI#getContactNumber', 'url' => '/contact-number', 'verb' => 'GET'],
['name' => 'googleAPI#importCalendar', 'url' => '/import-calendar', 'verb' => 'GET'],
['name' => 'googleAPI#importContacts', 'url' => '/import-contacts', 'verb' => 'GET'],
+ ['name' => 'googleAPI#createPickerSession', 'url' => '/picker-session', 'verb' => 'POST'],
+ ['name' => 'googleAPI#getPickerSession', 'url' => '/picker-session', 'verb' => 'GET'],
+ ['name' => 'googleAPI#deletePickerSession', 'url' => '/picker-session', 'verb' => 'DELETE'],
+ ['name' => 'googleAPI#importPhotos', 'url' => '/import-photos', 'verb' => 'GET'],
+ ['name' => 'googleAPI#getImportPhotosInformation', 'url' => '/import-photos-info', 'verb' => 'GET'],
['name' => 'googleAPI#importDrive', 'url' => '/import-files', 'verb' => 'GET'],
['name' => 'googleAPI#getImportDriveInformation', 'url' => '/import-files-info', 'verb' => 'GET'],
]
diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php
new file mode 100644
index 00000000..7fff27d8
--- /dev/null
+++ b/lib/BackgroundJob/ImportPhotosJob.php
@@ -0,0 +1,39 @@
+service->importPhotosJob($userId);
+ }
+}
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 35b70752..756e7749 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -17,6 +17,7 @@
use OCA\Google\AppInfo\Application;
use OCA\Google\Service\GoogleAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
+use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
@@ -41,8 +42,9 @@ class ConfigController extends Controller {
public const CONTACTS_OTHER_SCOPE = 'https://www.googleapis.com/auth/contacts.other.readonly';
public const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly';
public const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly';
+ public const PHOTOS_SCOPE = 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly';
- public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start'];
+ public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_import_timestamp', 'photo_import_job_last_start'];
public function __construct(
string $appName,
@@ -55,6 +57,7 @@ public function __construct(
private IInitialState $initialStateService,
private GoogleAPIService $googleApiService,
private GoogleDriveAPIService $googleDriveApiService,
+ private GooglePhotosAPIService $googlePhotosApiService,
private ?string $userId,
private ICrypto $crypto,
private SecretService $secretService,
@@ -62,6 +65,28 @@ public function __construct(
parent::__construct($appName, $request);
}
+ /**
+ * @NoAdminRequired
+ * Get current user config values (used after OAuth popup to refresh state)
+ *
+ * @return DataResponse
+ */
+ public function getConfig(): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ $userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true);
+ $userScopesString = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_scopes', '{}', lazy: true);
+ $userScopes = json_decode($userScopesString, true);
+ if (!is_array($userScopes)) {
+ $userScopes = [];
+ }
+ return new DataResponse([
+ 'user_name' => $userName,
+ 'user_scopes' => $userScopes,
+ ]);
+ }
+
/**
* @NoAdminRequired
* Set config values
@@ -90,6 +115,7 @@ public function setConfig(array $values): DataResponse {
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'refresh_token');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token_expires_at');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token');
+ $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'user_scopes');
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
@@ -100,6 +126,9 @@ public function setConfig(array $values): DataResponse {
if (isset($values['importing_drive']) && $values['importing_drive'] === '0') {
$this->googleDriveApiService->cancelImport($this->userId);
}
+ if (isset($values['importing_photos']) && $values['importing_photos'] === '0') {
+ $this->googlePhotosApiService->cancelImport($this->userId);
+ }
}
return new DataResponse($result);
}
@@ -192,6 +221,7 @@ public function oauthRedirect(string $code = '', string $state = '', string $sco
'can_access_contacts' => in_array(self::CONTACTS_SCOPE, $scopes) ? 1 : 0,
'can_access_other_contacts' => in_array(self::CONTACTS_OTHER_SCOPE, $scopes) ? 1 : 0,
'can_access_calendar' => (in_array(self::CALENDAR_SCOPE, $scopes) && in_array(self::CALENDAR_EVENTS_SCOPE, $scopes)) ? 1 : 0,
+ 'can_access_photos' => in_array(self::PHOTOS_SCOPE, $scopes) ? 1 : 0,
];
$this->userConfig->setValueString($this->userId, Application::APP_ID, 'user_scopes', json_encode($scopesArray), lazy: true);
diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php
index 1cf88d50..24eedfee 100644
--- a/lib/Controller/GoogleAPIController.php
+++ b/lib/Controller/GoogleAPIController.php
@@ -16,6 +16,7 @@
use OCA\Google\Service\GoogleCalendarAPIService;
use OCA\Google\Service\GoogleContactsAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
+use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
@@ -30,6 +31,7 @@ public function __construct(
string $appName,
IRequest $request,
private IUserConfig $userConfig,
+ private GooglePhotosAPIService $googlePhotosAPIService,
private GoogleContactsAPIService $googleContactsAPIService,
private GoogleDriveAPIService $googleDriveAPIService,
private GoogleCalendarAPIService $googleCalendarAPIService,
@@ -40,6 +42,99 @@ public function __construct(
$this->accessToken = $this->userId !== null ? $this->secretService->getEncryptedUserValue($this->userId, 'token') : '';
}
+
+ /**
+ * @NoAdminRequired
+ *
+ * @return DataResponse
+ */
+ public function getImportPhotosInformation(): DataResponse {
+ if ($this->accessToken === '') {
+ return new DataResponse([], 400);
+ }
+ return new DataResponse([
+ 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1',
+ 'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true),
+ 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true),
+ 'nb_photos_seen' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_photos_seen', lazy: true),
+ ]);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Create a new Google Photos Picker session (Picker API)
+ *
+ * @return DataResponse
+ */
+ public function createPickerSession(): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->createPickerSession($this->userId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Poll a Google Photos Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function getPickerSession(string $sessionId): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->getPickerSession($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Delete a Google Photos Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function deletePickerSession(string $sessionId): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->deletePickerSession($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Start downloading photos from a completed Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function importPhotos(string $sessionId = ''): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->startImportPhotos($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
/**
* @NoAdminRequired
*
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index 8c06fc95..4740787d 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -62,6 +62,18 @@ public function prepare(INotification $notification, string $languageCode): INot
$l = $this->factory->get('integration_google', $languageCode);
switch ($notification->getSubject()) {
+ case 'import_photos_finished':
+ /** @var array{nbImported?:string, targetPath: string} $p */
+ $p = $notification->getSubjectParameters();
+ $nbImported = (int)($p['nbImported'] ?? 0);
+ $targetPath = $p['targetPath'];
+ $content = $l->n('%n photo was imported from Google.', '%n photos were imported from Google.', $nbImported);
+
+ $notification->setParsedSubject($content)
+ ->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')))
+ ->setLink($this->url->linkToRouteAbsolute('files.view.index', ['dir' => $targetPath]));
+ return $notification;
+
case 'import_drive_finished':
/** @var array{nbImported?:string, targetPath: string} $p */
$p = $notification->getSubjectParameters();
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
new file mode 100644
index 00000000..abdd4a3a
--- /dev/null
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -0,0 +1,422 @@
+googleApiService->request(
+ $userId,
+ 'v1/sessions',
+ ['pickingConfig' => ['maxItemCount' => '2000']],
+ 'POST',
+ self::PICKER_BASE_URL,
+ );
+ if (isset($result['error'])) {
+ return $result;
+ }
+ // persist session id so the background job can use it
+ if (isset($result['id'])) {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $result['id'], lazy: true);
+ }
+ // append /autoclose so Google Photos closes its window after selection is done
+ if (isset($result['pickerUri'])) {
+ $result['pickerUri'] .= '/autoclose';
+ }
+ return $result;
+ }
+
+ /**
+ * Poll an existing Picker session to check if the user has finished selecting
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array{mediaItemsSet?:bool, pollingConfig?:array, expireTime?:string, error?:string}
+ */
+ public function getPickerSession(string $userId, string $sessionId): array {
+ return $this->googleApiService->request(
+ $userId,
+ 'v1/sessions/' . urlencode($sessionId),
+ [],
+ 'GET',
+ self::PICKER_BASE_URL,
+ );
+ }
+
+ /**
+ * Delete a Picker session (cleanup after import)
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array
+ */
+ public function deletePickerSession(string $userId, string $sessionId): array {
+ $result = $this->googleApiService->request(
+ $userId,
+ 'v1/sessions/' . urlencode($sessionId),
+ [],
+ 'DELETE',
+ self::PICKER_BASE_URL,
+ );
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ return $result;
+ }
+
+ /**
+ * Start a background import job for the given Picker session
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array{targetPath?:string, error?:string}
+ */
+ public function startImportPhotos(string $userId, string $sessionId): array {
+ $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $targetPath = $targetPath ?: '/Google Photos';
+
+ $alreadyImporting = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1';
+ if ($alreadyImporting) {
+ return ['targetPath' => $targetPath];
+ }
+
+ // create root folder
+ $userFolder = $this->root->getUserFolder($userId);
+ if (!$userFolder->nodeExists($targetPath)) {
+ $userFolder->newFolder($targetPath);
+ } else {
+ $folder = $userFolder->get($targetPath);
+ if (!($folder instanceof Folder)) {
+ return ['error' => 'Impossible to create Google Photos folder'];
+ }
+ }
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '1', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $sessionId, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ return ['targetPath' => $targetPath];
+ }
+
+ /**
+ * Cancel any pending import and optionally delete the active picker session
+ *
+ * @param string $userId
+ * @return void
+ */
+ public function cancelImport(string $userId): void {
+ $this->jobList->remove(ImportPhotosJob::class, ['user_id' => $userId]);
+ $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ if ($sessionId !== '') {
+ $this->deletePickerSession($userId, $sessionId);
+ }
+ }
+
+ /**
+ * Background job entry point: import a batch of photos then re-queue if unfinished
+ *
+ * @param string $userId
+ * @return void
+ */
+ public function importPhotosJob(string $userId): void {
+ $this->logger->debug('Importing photos (Picker API) for ' . $userId);
+
+ $this->userScopeService->setUserScope($userId);
+ $this->userScopeService->setFilesystemScope($userId);
+
+ $importingPhotos = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1';
+ if (!$importingPhotos) {
+ return;
+ }
+
+ $jobRunning = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true) === '1';
+ $nowTs = (new DateTime())->getTimestamp();
+ if ($jobRunning) {
+ $lastJobStart = $this->userConfig->getValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', lazy: true);
+ if ($lastJobStart !== 0 && ($nowTs - $lastJobStart < Application::IMPORT_JOB_TIMEOUT)) {
+ $this->logger->info(
+ 'Last job execution (' . strval($nowTs - $lastJobStart) . ') is less than '
+ . strval(Application::IMPORT_JOB_TIMEOUT) . ' seconds ago, delaying execution',
+ );
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ return;
+ }
+ }
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '1', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', $nowTs, lazy: true);
+
+ $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $targetPath = $targetPath ?: '/Google Photos';
+ $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ $alreadyImported = $this->userConfig->getValueInt($userId, Application::APP_ID, 'nb_imported_photos', lazy: true);
+
+ try {
+ $result = $this->importFromPickerSession($userId, $sessionId, $targetPath, 500000000, $alreadyImported);
+ } catch (Exception|Throwable $e) {
+ $result = ['error' => 'Unknown job failure. ' . $e->getMessage()];
+ }
+
+ if (isset($result['error']) || (isset($result['finished']) && $result['finished'])) {
+ if (isset($result['finished']) && $result['finished']) {
+ $this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [
+ 'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0),
+ 'targetPath' => $targetPath,
+ ]);
+ // Clean up the picker session now that we have all items
+ if ($sessionId !== '') {
+ $this->deletePickerSession($userId, $sessionId);
+ }
+ }
+ if (isset($result['error'])) {
+ $this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]);
+ }
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ } else {
+ $ts = (new DateTime())->getTimestamp();
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', $ts, lazy: true);
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ }
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
+ }
+
+ /**
+ * Download picked media items from a Picker session into a Nextcloud folder.
+ * Processes up to $maxDownloadSize bytes per call, then returns so the job can re-queue.
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @param string $targetPath
+ * @param int|null $maxDownloadSize
+ * @param int $alreadyImported
+ * @return array
+ */
+ public function importFromPickerSession(
+ string $userId, string $sessionId, string $targetPath,
+ ?int $maxDownloadSize = null, int $alreadyImported = 0,
+ ): array {
+ if ($sessionId === '') {
+ return ['error' => 'No picker session ID stored'];
+ }
+
+ $userFolder = $this->root->getUserFolder($userId);
+ if (!$userFolder->nodeExists($targetPath)) {
+ $folder = $userFolder->newFolder($targetPath);
+ } else {
+ $folder = $userFolder->get($targetPath);
+ if (!$folder instanceof Folder) {
+ return ['error' => 'Impossible to create Google Photos folder'];
+ }
+ }
+
+ // Load cross-session dedup state, scoped to the current target folder.
+ // If the user has changed the output folder, reset the ID list so photos
+ // can be imported again into the new (empty) location.
+ $dedupTargetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_dedup_target_path', '', lazy: true);
+ if ($dedupTargetPath !== $targetPath) {
+ $importedIds = [];
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_dedup_target_path', $targetPath, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true);
+ } else {
+ $importedIdsRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'imported_photo_ids', '{}', lazy: true);
+ $importedIds = json_decode($importedIdsRaw, true) ?? [];
+ }
+
+ // Page through all picked media items
+ $downloadedSize = 0;
+ $nbDownloaded = 0;
+ $totalSeenNumber = 0;
+ $params = ['sessionId' => $sessionId, 'pageSize' => 100];
+ $resumeToken = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ if ($resumeToken !== '') {
+ $params['pageToken'] = $resumeToken;
+ }
+
+ do {
+ $currentPageToken = $params['pageToken'] ?? '';
+ $result = $this->googleApiService->request(
+ $userId,
+ 'v1/mediaItems',
+ $params,
+ 'GET',
+ self::PICKER_BASE_URL,
+ );
+ if (isset($result['error'])) {
+ return $result;
+ }
+ $items = $result['mediaItems'] ?? [];
+ foreach ($items as $item) {
+ $totalSeenNumber++;
+ $itemId = $item['id'] ?? '';
+ // Skip photos already imported in a previous session
+ if ($itemId !== '' && array_key_exists($itemId, $importedIds)) {
+ continue;
+ }
+ $size = $this->downloadPickerItem($userId, $item, $folder);
+ if ($size !== null) {
+ $nbDownloaded++;
+ if ($itemId !== '') {
+ $importedIds[$itemId] = 1;
+ }
+ $this->userConfig->setValueInt(
+ $userId, Application::APP_ID, 'nb_imported_photos',
+ $alreadyImported + $nbDownloaded, lazy: true,
+ );
+ $downloadedSize += $size;
+ if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) {
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [
+ 'nbDownloaded' => $nbDownloaded,
+ 'targetPath' => $targetPath,
+ 'finished' => false,
+ 'totalSeen' => $totalSeenNumber,
+ ];
+ }
+ }
+ }
+ // Update progress counters after each page
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true);
+ $params['pageToken'] = $result['nextPageToken'] ?? '';
+ } while (isset($result['nextPageToken']));
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ return [
+ 'nbDownloaded' => $nbDownloaded,
+ 'targetPath' => $targetPath,
+ 'finished' => true,
+ 'totalSeen' => $totalSeenNumber,
+ ];
+ }
+
+ /**
+ * Download a single PickedMediaItem into a Nextcloud folder.
+ *
+ * @param string $userId
+ * @param array{id:string, mediaFile?:array, createTime?:string} $item
+ * @param Folder $folder
+ * @return int|null downloaded byte count, or null if skipped/error
+ */
+ private function downloadPickerItem(string $userId, array $item, Folder $folder): ?int {
+ $mediaFile = $item['mediaFile'] ?? null;
+ if ($mediaFile === null) {
+ return null;
+ }
+
+ $baseUrl = $mediaFile['baseUrl'] ?? '';
+ if ($baseUrl === '') {
+ return null;
+ }
+
+ $mimeType = $mediaFile['mimeType'] ?? '';
+ $rawName = $mediaFile['filename'] ?? ($item['id'] ?? 'unknown');
+ $fileName = $this->fileUtils->sanitizeFilename($rawName, (string)($item['id'] ?? ''));
+
+ // Avoid duplicate filenames
+ if ($folder->nodeExists($fileName)) {
+ $fileName = ($item['id'] ?? 'dup') . '_' . $fileName;
+ }
+ if ($folder->nodeExists($fileName)) {
+ return null; // already imported
+ }
+
+ // Build the download URL: images get =d (full quality + EXIF), videos get =dv
+ $isVideo = str_starts_with($mimeType, 'video/');
+ $downloadUrl = $isVideo ? ($baseUrl . '=dv') : ($baseUrl . '=d');
+
+ $savedFile = $folder->newFile($fileName);
+ try {
+ $resource = $savedFile->fopen('w');
+ } catch (LockedException $e) {
+ $this->logger->warning('Google Photo, error opening target file: file is locked', ['app' => Application::APP_ID]);
+ if ($savedFile->isDeletable()) {
+ $savedFile->delete();
+ }
+ return null;
+ }
+ if ($resource === false) {
+ $this->logger->warning('Google Photo, error opening target file', ['app' => Application::APP_ID]);
+ if ($savedFile->isDeletable()) {
+ $savedFile->delete();
+ }
+ return null;
+ }
+
+ $res = $this->googleApiService->simpleDownload($userId, $downloadUrl, $resource);
+ if (!isset($res['error'])) {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if (isset($item['createTime'])) {
+ $d = new DateTime($item['createTime']);
+ $savedFile->touch($d->getTimestamp());
+ } else {
+ $savedFile->touch();
+ }
+ $stat = $savedFile->stat();
+ return (int)($stat['size'] ?? 0);
+ } else {
+ $this->logger->warning('Google API error downloading photo: ' . $res['error'], ['app' => Application::APP_ID]);
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if ($savedFile->isDeletable()) {
+ $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
+ $savedFile->delete();
+ }
+ }
+ return null;
+ }
+}
diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php
index 50c85112..af2867ca 100644
--- a/lib/Settings/Personal.php
+++ b/lib/Settings/Personal.php
@@ -45,6 +45,8 @@ public function getForm(): TemplateResponse {
$userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true);
$driveOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_output_dir', '/Google Drive', lazy: true);
$driveOutputDir = $driveOutputDir ?: '/Google Drive';
+ $photoOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $photoOutputDir = $photoOutputDir ?: '/Google Photos';
$driveSharedWithMeOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_shared_with_me_output_dir', '/Google Drive/Shared with me', lazy: true);
$driveSharedWithMeOutputDir = $driveSharedWithMeOutputDir ?: '/Google Drive/Shared with me';
$considerAllEvents = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'consider_all_events', '1', lazy: true) === '1';
@@ -94,6 +96,7 @@ public function getForm(): TemplateResponse {
'document_format' => $documentFormat,
'drive_output_dir' => $driveOutputDir,
'drive_shared_with_me_output_dir' => $driveSharedWithMeOutputDir,
+ 'photo_output_dir' => $photoOutputDir,
'user_scopes' => $userScopes,
];
$this->initialStateService->provideInitialState('user-config', $userConfig);
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index e35d6977..e5408675 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -25,7 +25,7 @@
{{ t('integration_google', 'Put the "Client ID" and "Client secret" below.') }}
- {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", and "People API".') }}
+ {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", "People API" and "Google Photos Picker API".') }}
{{ t('integration_google', 'Your Nextcloud users will then see a "Connect to Google" button in their personal settings.') }}
+
+ {{ t('integration_google', 'Up to 2,000 photos can be imported per session. Hold Shift and click to select many photos at once in the Google Photos picker.') }}
+
+
+
+ {{ t('integration_google', 'Warning: Google does not provide location data in imported photos.') }}
+
{{ t('integration_google', 'Waiting for you to finish your selection in the Google Photos window…') }}
@@ -156,14 +156,14 @@
- {{ t('integration_google', 'Cancel') }}
+ {{ t('integration_google', 'Cancel photo picking') }}
{{ t('integration_google', 'Waiting for you to finish your selection in the Google Photos window…') }}
@@ -226,7 +226,7 @@
- {{ t('integration_google', 'Cancel') }}
+ {{ t('integration_google', 'Cancel photo picking') }}
@@ -1166,8 +1166,14 @@ export default {
width: 300px;
}
+ .picker-session-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
.cancel-session-btn {
- margin-top: 8px;
+ margin-top: 0;
}
#google-contacts {
From d1373d5dbb89340305e4640fb8c6928e22087116 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Thu, 9 Apr 2026 23:54:23 +0100
Subject: [PATCH 13/25] fix(photos): avoid transient importing_photos=0 on
queue transition; null photoImportLoop after cancel
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GooglePhotosAPIService.php | 15 ++++++++++-----
src/components/PersonalSettings.vue | 1 +
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 35b4b71a..e458c56d 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -239,23 +239,28 @@ public function importPhotosJob(string $userId): void {
$this->deletePickerSession($userId, $sessionId);
}
}
- $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
- // On successful completion, start the next queued session if any
+ // On successful completion, atomically transition to the next queued session if any,
+ // so importing_photos never has a transient '0' that would stop the polling client.
if (isset($result['finished']) && $result['finished']) {
$queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
$queue = json_decode($queueRaw, true) ?? [];
if (!empty($queue)) {
$nextSessionId = array_shift($queue);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
- $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '1', lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $nextSessionId, lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
$this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ } else {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
}
+ } else {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
}
} else {
$ts = (new DateTime())->getTimestamp();
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index e48adfbf..4574a7ca 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -924,6 +924,7 @@ export default {
onCancelPhotoImport() {
this.importingPhotos = false
clearInterval(this.photoImportLoop)
+ this.photoImportLoop = null
const req = {
values: {
importing_photos: '0',
From 2997756726973ac02fc94d11ce6db14e85ecbeff Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 00:09:22 +0100
Subject: [PATCH 14/25] fix(photos): address Copilot review comments round 6
- deletePickerSession() now only clears the stored picker_session_id
when it matches the session being deleted, so cancelling a UI/queued
session cannot corrupt the active import session ID
- createPickerSession() no longer writes picker_session_id; only
startImportPhotos() sets that key, preventing an in-progress import
session from being overwritten when the user opens a second picker
- BroadcastChannel onmessage handler now validates event.data.username
before calling bc.close(), so a stray same-origin message cannot
close the channel prematurely and swallow the real OAuth response
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GooglePhotosAPIService.php | 12 +++++++-----
src/components/PersonalSettings.vue | 3 +++
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index e458c56d..85f7b696 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -67,10 +67,6 @@ public function createPickerSession(string $userId): array {
if (isset($result['error'])) {
return $result;
}
- // persist session id so the background job can use it
- if (isset($result['id'])) {
- $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $result['id'], lazy: true);
- }
// append /autoclose so Google Photos closes its window after selection is done
if (isset($result['pickerUri'])) {
$result['pickerUri'] .= '/autoclose';
@@ -98,6 +94,9 @@ public function getPickerSession(string $userId, string $sessionId): array {
/**
* Delete a Picker session (cleanup after import)
*
+ * Only clears the stored picker_session_id when it matches the session being deleted,
+ * so deleting a UI/queued session does not corrupt the active import session ID.
+ *
* @param string $userId
* @param string $sessionId
* @return array
@@ -110,7 +109,10 @@ public function deletePickerSession(string $userId, string $sessionId): array {
'DELETE',
self::PICKER_BASE_URL,
);
- $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ $storedSessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ if ($storedSessionId === $sessionId) {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ }
return $result;
}
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index 4574a7ca..fc81d98b 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -602,6 +602,9 @@ export default {
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel('integration_google_oauth')
bc.onmessage = (event) => {
+ if (!event.data?.username) {
+ return
+ }
bc.close()
handleOAuthMessage(event)
}
From 0eb485a93718fd13bdb0f392c0e648fac68a258f Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:17:30 +0100
Subject: [PATCH 15/25] fix(photos): address Copilot review comments round 7
- Remove : void return type from ImportPhotosJob::run() to match
QueuedJob parent signature and stay consistent with ImportDriveJob
- Drop BroadcastChannel + opener fallback chain in favour of
BroadcastChannel only: popupSuccess.js now directly calls
new BroadcastChannel / postMessage / close; PersonalSettings.vue
drops the window.addEventListener fallback branches. The OAuth popup
is always opened with noopener,noreferrer, which made the opener
path unreachable and dead code
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/BackgroundJob/ImportPhotosJob.php | 3 +--
src/components/PersonalSettings.vue | 32 +++++----------------------
src/popupSuccess.js | 27 +++-------------------
3 files changed, 10 insertions(+), 52 deletions(-)
diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php
index 7fff27d8..04892b2c 100644
--- a/lib/BackgroundJob/ImportPhotosJob.php
+++ b/lib/BackgroundJob/ImportPhotosJob.php
@@ -30,9 +30,8 @@ public function __construct(
/**
* @param array{user_id:string} $argument
- * @return void
*/
- public function run($argument): void {
+ public function run($argument) {
$userId = $argument['user_id'];
$this->service->importPhotosJob($userId);
}
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index fc81d98b..ee01a88a 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -598,33 +598,13 @@ export default {
this.loadData()
})
}
- try {
- if (typeof BroadcastChannel !== 'undefined') {
- const bc = new BroadcastChannel('integration_google_oauth')
- bc.onmessage = (event) => {
- if (!event.data?.username) {
- return
- }
- bc.close()
- handleOAuthMessage(event)
- }
- } else {
- window.addEventListener('message', function listener(event) {
- if (event.origin !== window.location.origin || !event.data?.username) {
- return
- }
- window.removeEventListener('message', listener)
- handleOAuthMessage(event)
- })
+ const bc = new BroadcastChannel('integration_google_oauth')
+ bc.onmessage = (event) => {
+ if (!event.data?.username) {
+ return
}
- } catch (e) {
- window.addEventListener('message', function listener(event) {
- if (event.origin !== window.location.origin || !event.data?.username) {
- return
- }
- window.removeEventListener('message', listener)
- handleOAuthMessage(event)
- })
+ bc.close()
+ handleOAuthMessage(event)
}
} else {
window.location.replace(requestUrl)
diff --git a/src/popupSuccess.js b/src/popupSuccess.js
index 250e6297..6dcda6a5 100644
--- a/src/popupSuccess.js
+++ b/src/popupSuccess.js
@@ -3,28 +3,7 @@ import { loadState } from '@nextcloud/initial-state'
const state = loadState('integration_google', 'popup-data')
const username = state.user_name
-const notifyOpener = (message) => {
- try {
- if (typeof BroadcastChannel !== 'undefined') {
- const bc = new BroadcastChannel('integration_google_oauth')
- try {
- bc.postMessage(message)
- return
- } finally {
- bc.close()
- }
- }
- } catch (e) {
- // fall through to same-origin opener fallback
- }
- try {
- if (window.opener && window.opener.location?.origin === window.location.origin) {
- window.opener.postMessage(message, window.location.origin)
- }
- } catch (e) {
- // ignore cross-origin/access errors
- }
-}
-
-notifyOpener({ username })
+const bc = new BroadcastChannel('integration_google_oauth')
+bc.postMessage({ username })
+bc.close()
window.close()
From b30ecd3cf5c06e8bbf9789de4a76a568c01901c7 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:39:07 +0100
Subject: [PATCH 16/25] fix(photos): address Copilot review comments round 8
- popupSuccess.js: wrap BroadcastChannel send in try/finally so
window.close() always runs and feature-detect BroadcastChannel
- GooglePhotosAPIService.php: reset photo_import_running and
photo_import_job_last_start before early return when importing_photos=0
- GooglePhotosAPIService.php: use is_array() guard when appending to
json-decoded picker_session_queue in startImportPhotos
- GoogleAPIController.php: use is_array() guard before count() on
json-decoded picker_session_queue
- PersonalSettings.vue: store BroadcastChannel on component as
oauthBroadcastChannel, close previous instance before creating a new
one, and close in beforeUnmount to prevent channel leaks
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Controller/GoogleAPIController.php | 9 ++++++++-
lib/Service/GooglePhotosAPIService.php | 8 +++++++-
package-lock.json | 4 ++--
src/components/PersonalSettings.vue | 16 +++++++++++++---
src/popupSuccess.js | 13 +++++++++----
5 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php
index 67bf4bc8..9da72438 100644
--- a/lib/Controller/GoogleAPIController.php
+++ b/lib/Controller/GoogleAPIController.php
@@ -52,11 +52,18 @@ public function getImportPhotosInformation(): DataResponse {
if ($this->accessToken === '') {
return new DataResponse([], 400);
}
+ $pickerSessionQueue = json_decode(
+ $this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true),
+ true,
+ );
+ if (!is_array($pickerSessionQueue)) {
+ $pickerSessionQueue = [];
+ }
return new DataResponse([
'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1',
'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true),
'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true),
- 'nb_queued_sessions' => count(json_decode($this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true), true) ?? []),
+ 'nb_queued_sessions' => count($pickerSessionQueue),
]);
}
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 85f7b696..92e3a3d7 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -135,7 +135,10 @@ public function startImportPhotos(string $userId, string $sessionId): array {
if ($alreadyImporting) {
// Queue this session to run after the current one finishes
$queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
- $queue = json_decode($queueRaw, true) ?? [];
+ $queue = json_decode($queueRaw, true);
+ if (!is_array($queue)) {
+ $queue = [];
+ }
$queue[] = $sessionId;
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
return ['targetPath' => $targetPath, 'queued' => true];
@@ -192,6 +195,9 @@ public function importPhotosJob(string $userId): void {
$importingPhotos = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1';
if (!$importingPhotos) {
+ // Clear the concurrency guard so it does not delay future imports
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', 0, lazy: true);
return;
}
diff --git a/package-lock.json b/package-lock.json
index d927cb44..847284e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,8 +31,8 @@
"vite-plugin-stylelint": "^6.0.0"
},
"engines": {
- "node": "^22.0.0",
- "npm": "^10.5.0"
+ "node": "^24.0.0",
+ "npm": "^11.3.0"
}
},
"node_modules/@babel/code-frame": {
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index ee01a88a..2ad3a97c 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -419,6 +419,7 @@ export default {
nbImportedPhotos: 0,
queuedSessions: 0,
photoImportLoop: null,
+ oauthBroadcastChannel: null,
// drive
driveSize: 0,
gettingDriveInfo: false,
@@ -478,6 +479,10 @@ export default {
this.pickerPollTimer = null
clearInterval(this.photoImportLoop)
this.photoImportLoop = null
+ if (this.oauthBroadcastChannel) {
+ this.oauthBroadcastChannel.close()
+ this.oauthBroadcastChannel = null
+ }
},
mounted() {
@@ -598,12 +603,17 @@ export default {
this.loadData()
})
}
- const bc = new BroadcastChannel('integration_google_oauth')
- bc.onmessage = (event) => {
+ // Close any previous channel before creating a new one
+ if (this.oauthBroadcastChannel) {
+ this.oauthBroadcastChannel.close()
+ }
+ this.oauthBroadcastChannel = new BroadcastChannel('integration_google_oauth')
+ this.oauthBroadcastChannel.onmessage = (event) => {
if (!event.data?.username) {
return
}
- bc.close()
+ this.oauthBroadcastChannel.close()
+ this.oauthBroadcastChannel = null
handleOAuthMessage(event)
}
} else {
diff --git a/src/popupSuccess.js b/src/popupSuccess.js
index 6dcda6a5..0f76e698 100644
--- a/src/popupSuccess.js
+++ b/src/popupSuccess.js
@@ -3,7 +3,12 @@ import { loadState } from '@nextcloud/initial-state'
const state = loadState('integration_google', 'popup-data')
const username = state.user_name
-const bc = new BroadcastChannel('integration_google_oauth')
-bc.postMessage({ username })
-bc.close()
-window.close()
+try {
+ if (typeof BroadcastChannel !== 'undefined') {
+ const bc = new BroadcastChannel('integration_google_oauth')
+ bc.postMessage({ username })
+ bc.close()
+ }
+} finally {
+ window.close()
+}
From ed03d8bbaf59093cf1de6a8d9e991ba1282160dd Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:51:30 +0100
Subject: [PATCH 17/25] fix(photos): clear picker session queue and page token
on import error
On the error path in importPhotosJob(), picker_session_queue and
photo_next_page_token were not cleared. Any queued sessions would remain
stuck in config and never be processed. Now both are reset on error so
the UI is consistent and no phantom queued sessions are left behind.
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GooglePhotosAPIService.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 92e3a3d7..5a6ef9b7 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -246,6 +246,9 @@ public function importPhotosJob(string $userId): void {
if ($sessionId !== '') {
$this->deletePickerSession($userId, $sessionId);
}
+ // Clear the queue and page token so queued sessions are not left stuck after a failure
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
}
// On successful completion, atomically transition to the next queued session if any,
// so importing_photos never has a transient '0' that would stop the polling client.
From 20f7ce3f1f666636d85769953514cacfd312a28a Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:45:26 +0100
Subject: [PATCH 18/25] fix(photos): address maintainer review comments
- Update copyright headers in new PHP files to
'Nextcloud GmbH and Nextcloud contributors 2025'
with @author Ahsan Ahmed
- Revert package-lock.json engine version bumps
(these are auto-managed in separate PRs)
- Refactor file download logic into GoogleAPIService::downloadAndSaveFile()
so it can be reused by both GooglePhotosAPIService and GoogleDriveAPIService;
remove the now-redundant private downloadAndSaveFile from GoogleDriveAPIService
- Add ImportPhotosJob MissingOverrideAttribute to psalm-baseline.xml
- Fix InvalidReturnType/InvalidReturnStatement in GooglePhotosAPIService
via updated return-type docblocks and inline psalm-suppress annotations
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/BackgroundJob/ImportPhotosJob.php | 4 +-
lib/Service/GoogleAPIService.php | 64 ++++++++++++++++++
lib/Service/GoogleDriveAPIService.php | 71 ++++----------------
lib/Service/GooglePhotosAPIService.php | 91 ++++++++------------------
package-lock.json | 4 +-
psalm-baseline.xml | 5 ++
6 files changed, 111 insertions(+), 128 deletions(-)
diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php
index 04892b2c..56150eeb 100644
--- a/lib/BackgroundJob/ImportPhotosJob.php
+++ b/lib/BackgroundJob/ImportPhotosJob.php
@@ -6,8 +6,8 @@
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
- * @author Julien Veyssier
- * @copyright Julien Veyssier 2020
+ * @author Ahsan Ahmed
+ * @copyright Nextcloud GmbH and Nextcloud contributors 2025
*/
namespace OCA\Google\BackgroundJob;
diff --git a/lib/Service/GoogleAPIService.php b/lib/Service/GoogleAPIService.php
index ee2b6cc8..e4ed8d82 100644
--- a/lib/Service/GoogleAPIService.php
+++ b/lib/Service/GoogleAPIService.php
@@ -20,9 +20,14 @@
use OCA\Google\AppInfo\Application;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
+use OCP\Files\Folder;
+use OCP\Files\InvalidPathException;
+use OCP\Files\NotPermittedException;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IL10N;
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -353,6 +358,65 @@ public function simpleDownload(string $userId, string $url, $resource, array $pa
}
}
+ /**
+ * Download content from a URL and save it as a new file in a folder.
+ * Handles resource management, timestamp setting, and cleanup on failure.
+ *
+ * @param string $userId
+ * @param Folder $saveFolder Target Nextcloud folder
+ * @param string $fileName Name of the file to create
+ * @param string $fileUrl URL to download from
+ * @param int|null $mtime Unix timestamp for the file modification time; uses current time if null
+ * @param array $params Additional HTTP query parameters
+ * @return int|null downloaded file size in bytes, or null on failure
+ */
+ public function downloadAndSaveFile(
+ string $userId,
+ Folder $saveFolder,
+ string $fileName,
+ string $fileUrl,
+ ?int $mtime = null,
+ array $params = [],
+ ): ?int {
+ try {
+ $savedFile = $saveFolder->newFile($fileName);
+ } catch (NotPermittedException|InvalidPathException $e) {
+ return null;
+ }
+
+ try {
+ $resource = $savedFile->fopen('w');
+ } catch (LockedException $e) {
+ return null;
+ }
+ if ($resource === false) {
+ return null;
+ }
+
+ $res = $this->simpleDownload($userId, $fileUrl, $resource, $params);
+ if (!isset($res['error'])) {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if ($mtime !== null) {
+ $savedFile->touch($mtime);
+ } else {
+ $savedFile->touch();
+ }
+ $stat = $savedFile->stat();
+ return (int)($stat['size'] ?? 0);
+ } else {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if ($savedFile->isDeletable()) {
+ $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
+ $savedFile->delete();
+ }
+ }
+ return null;
+ }
+
private function checkTokenExpiration(string $userId): void {
$refreshToken = $this->secretService->getEncryptedUserValue($userId, 'refresh_token');
$expireAt = $this->userConfig->getValueInt($userId, Application::APP_ID, 'token_expires_at', lazy: true);
diff --git a/lib/Service/GoogleDriveAPIService.php b/lib/Service/GoogleDriveAPIService.php
index 39e0d678..70c70800 100644
--- a/lib/Service/GoogleDriveAPIService.php
+++ b/lib/Service/GoogleDriveAPIService.php
@@ -25,7 +25,6 @@
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
-use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use Psr\Log\LoggerInterface;
@@ -550,61 +549,6 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder,
/**
* Create new file in the given folder with given filename
- * Download contents of the file from Google Drive and save it into the created file
- * @param Folder $saveFolder
- * @param string $fileName
- * @param string $userId
- * @param string $fileUrl
- * @param array $fileItem
- * @param array $params
- * @return ?int downloaded size, null if error during file creation or download
- * @throws InvalidPathException
- * @throws LockedException
- * @throws NotFoundException
- * @throws NotPermittedException
- */
- private function downloadAndSaveFile(
- Folder $saveFolder, string $fileName, string $userId,
- string $fileUrl, array $fileItem, array $params = [],
- ): ?int {
- try {
- $savedFile = $saveFolder->newFile($fileName);
- } catch (NotPermittedException $e) {
- return null;
- }
-
- try {
- $resource = $savedFile->fopen('w');
- } catch (LockedException $e) {
- return null;
- }
- if ($resource === false) {
- return null;
- }
-
- $res = $this->googleApiService->simpleDownload($userId, $fileUrl, $resource, $params);
- if (!isset($res['error'])) {
- if (is_resource($resource)) {
- fclose($resource);
- }
- if (isset($fileItem['modifiedTime'])) {
- $d = new DateTime($fileItem['modifiedTime']);
- $ts = $d->getTimestamp();
- $savedFile->touch($ts);
- } else {
- $savedFile->touch();
- }
- $stat = $savedFile->stat();
- return $stat['size'] ?? 0;
- } else {
- if ($savedFile->isDeletable()) {
- $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
- $savedFile->delete();
- }
- }
- return null;
- }
-
/**
* @param array $fileItem
* @param string $userId
@@ -692,12 +636,21 @@ private function getDocumentRequestParams(string $mimeType, string $documentForm
* @throws NotPermittedException
*/
private function getFile(string $userId, array $fileItem, Folder $saveFolder, string $fileName): ?int {
+ $mtime = null;
+ if (isset($fileItem['modifiedTime'])) {
+ try {
+ $mtime = (new DateTime($fileItem['modifiedTime']))->getTimestamp();
+ } catch (Exception $e) {
+ // fall through: use null (current time)
+ }
+ }
+
if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) {
$documentFormat = $this->getUserDocumentFormat($userId);
// potentially a doc
$params = $this->getDocumentRequestParams($fileItem['mimeType'], $documentFormat);
$fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '/export';
- $result = $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem, $params);
+ $result = $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime, $params);
if ($result !== null) {
return $result;
}
@@ -727,11 +680,11 @@ private function getFile(string $userId, array $fileItem, Folder $saveFolder, st
}
}
$this->logger->debug('Document export succeeded', ['fileItem' => $fileItem, 'fileUrl' => $fileUrl]);
- return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem);
+ return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime);
} elseif (isset($fileItem['webContentLink'])) {
// classic file
$fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '?alt=media';
- return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem);
+ return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime);
}
return null;
}
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 5a6ef9b7..5ccbdd77 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -6,8 +6,8 @@
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
- * @author Julien Veyssier
- * @copyright Julien Veyssier 2020
+ * @author Ahsan Ahmed
+ * @copyright Nextcloud GmbH and Nextcloud contributors 2025
*/
namespace OCA\Google\Service;
@@ -20,12 +20,8 @@
use OCP\BackgroundJob\IJobList;
use OCP\Config\IUserConfig;
use OCP\Files\Folder;
-use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
-use OCP\Files\NotPermittedException;
use OCP\FilesMetadata\IFilesMetadataManager;
-use OCP\Lock\ILockingProvider;
-use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -54,7 +50,7 @@ public function __construct(
* Create a new Picker API session (max 2000 items per session)
*
* @param string $userId
- * @return array{id?:string, pickerUri?:string, pollingConfig?:array, expireTime?:string, error?:string}
+ * @return array{id?:string, pickerUri?:string, pollingConfig?:array, expireTime?:string, error?:mixed}
*/
public function createPickerSession(string $userId): array {
$result = $this->googleApiService->request(
@@ -65,12 +61,14 @@ public function createPickerSession(string $userId): array {
self::PICKER_BASE_URL,
);
if (isset($result['error'])) {
+ /** @psalm-suppress InvalidReturnStatement */
return $result;
}
// append /autoclose so Google Photos closes its window after selection is done
if (isset($result['pickerUri'])) {
$result['pickerUri'] .= '/autoclose';
}
+ /** @psalm-suppress InvalidReturnStatement */
return $result;
}
@@ -121,7 +119,7 @@ public function deletePickerSession(string $userId, string $sessionId): array {
*
* @param string $userId
* @param string $sessionId
- * @return array{targetPath?:string, error?:string}
+ * @return array{targetPath?:string, error?:string, queued?:true}
*/
public function startImportPhotos(string $userId, string $sessionId): array {
if (trim($sessionId) === '') {
@@ -414,69 +412,32 @@ private function downloadPickerItem(string $userId, array $item, Folder $folder)
$isVideo = str_starts_with($mimeType, 'video/');
$downloadUrl = $isVideo ? ($baseUrl . '=dv') : ($baseUrl . '=d');
- try {
- $savedFile = $folder->newFile($fileName);
- } catch (NotPermittedException|InvalidPathException $e) {
- $this->logger->warning('Google Photo, skipping file creation for picker item: ' . $e->getMessage(), ['app' => Application::APP_ID]);
- return null;
- }
-
- try {
- $resource = $savedFile->fopen('w');
- } catch (LockedException $e) {
- $this->logger->warning('Google Photo, error opening target file: file is locked', ['app' => Application::APP_ID]);
- if ($savedFile->isDeletable()) {
- $savedFile->delete();
+ $mtime = null;
+ if (isset($item['createTime'])) {
+ try {
+ $mtime = (new DateTime($item['createTime']))->getTimestamp();
+ } catch (Exception $e) {
+ $this->logger->warning('Google Photo, invalid createTime, using current time: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
- return null;
}
- if ($resource === false) {
- $this->logger->warning('Google Photo, error opening target file', ['app' => Application::APP_ID]);
- if ($savedFile->isDeletable()) {
- $savedFile->delete();
- }
+
+ $size = $this->googleApiService->downloadAndSaveFile($userId, $folder, $fileName, $downloadUrl, $mtime);
+ if ($size === null) {
return null;
}
- $res = $this->googleApiService->simpleDownload($userId, $downloadUrl, $resource);
- if (!isset($res['error'])) {
- if (is_resource($resource)) {
- fclose($resource);
- }
- if (isset($item['createTime'])) {
- try {
- $d = new DateTime($item['createTime']);
- $savedFile->touch($d->getTimestamp());
- } catch (Exception $e) {
- $this->logger->warning('Google Photo, invalid createTime, using current time: ' . $e->getMessage(), ['app' => Application::APP_ID]);
- $savedFile->touch();
- }
- } else {
- $savedFile->touch();
- }
- // Store the Google media ID in file metadata so future imports can
- // identify this file by ID rather than filename alone.
- if ($itemId !== '') {
- try {
- $meta = $this->metadataManager->getMetadata($savedFile->getId(), true);
- $meta->setString(self::METADATA_KEY, $itemId);
- $this->metadataManager->saveMetadata($meta);
- } catch (\Throwable $e) {
- $this->logger->warning('Google Photo, could not save file metadata: ' . $e->getMessage(), ['app' => Application::APP_ID]);
- }
- }
- $stat = $savedFile->stat();
- return (int)($stat['size'] ?? 0);
- } else {
- $this->logger->warning('Google API error downloading photo: ' . $res['error'], ['app' => Application::APP_ID]);
- if (is_resource($resource)) {
- fclose($resource);
- }
- if ($savedFile->isDeletable()) {
- $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
- $savedFile->delete();
+ // Store the Google media ID in file metadata so future imports can
+ // identify this file by ID rather than filename alone.
+ if ($itemId !== '') {
+ try {
+ $savedFile = $folder->get($fileName);
+ $meta = $this->metadataManager->getMetadata($savedFile->getId(), true);
+ $meta->setString(self::METADATA_KEY, $itemId);
+ $this->metadataManager->saveMetadata($meta);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Google Photo, could not save file metadata: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
}
- return null;
+ return $size;
}
}
diff --git a/package-lock.json b/package-lock.json
index 847284e5..d927cb44 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,8 +31,8 @@
"vite-plugin-stylelint": "^6.0.0"
},
"engines": {
- "node": "^24.0.0",
- "npm": "^11.3.0"
+ "node": "^22.0.0",
+ "npm": "^10.5.0"
}
},
"node_modules/@babel/code-frame": {
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 9bc0d5f7..21a07d29 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -11,6 +11,11 @@
+
+
+
+
+
From c925e35e3428fab8a2abd835480d0b993614cbba Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:50:57 +0100
Subject: [PATCH 19/25] fix(photos): correct copyright year to 2026
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/BackgroundJob/ImportPhotosJob.php | 2 +-
lib/Service/GooglePhotosAPIService.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php
index 56150eeb..01610b2e 100644
--- a/lib/BackgroundJob/ImportPhotosJob.php
+++ b/lib/BackgroundJob/ImportPhotosJob.php
@@ -7,7 +7,7 @@
* later. See the COPYING file.
*
* @author Ahsan Ahmed
- * @copyright Nextcloud GmbH and Nextcloud contributors 2025
+ * @copyright Nextcloud GmbH and Nextcloud contributors 2026
*/
namespace OCA\Google\BackgroundJob;
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 5ccbdd77..8a779af2 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -7,7 +7,7 @@
* later. See the COPYING file.
*
* @author Ahsan Ahmed
- * @copyright Nextcloud GmbH and Nextcloud contributors 2025
+ * @copyright Nextcloud GmbH and Nextcloud contributors 2026
*/
namespace OCA\Google\Service;
From 18cc183ca782dad85f40adf85a14e53f659e0b04 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 20:17:25 +0100
Subject: [PATCH 20/25] fix(photos): address Copilot review comments round 9
- Clear driveImportLoop in beforeUnmount() to prevent Drive polling
timer leak when component is destroyed (#92)
- Move pickerPollTimer clearance into onImportPhotos() success handler;
restart polling on import request failure so user is not stuck with
an active session and no way to trigger import (#93)
- Delete newly created file in downloadAndSaveFile() when fopen throws
LockedException or returns false, preventing zero-byte placeholder
files from being left behind on early failure paths (#94)
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GoogleAPIService.php | 6 ++++++
src/components/PersonalSettings.vue | 9 ++++++++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/lib/Service/GoogleAPIService.php b/lib/Service/GoogleAPIService.php
index e4ed8d82..224e6aed 100644
--- a/lib/Service/GoogleAPIService.php
+++ b/lib/Service/GoogleAPIService.php
@@ -387,9 +387,15 @@ public function downloadAndSaveFile(
try {
$resource = $savedFile->fopen('w');
} catch (LockedException $e) {
+ if ($savedFile->isDeletable()) {
+ $savedFile->delete();
+ }
return null;
}
if ($resource === false) {
+ if ($savedFile->isDeletable()) {
+ $savedFile->delete();
+ }
return null;
}
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index 2ad3a97c..5810c537 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -479,6 +479,8 @@ export default {
this.pickerPollTimer = null
clearInterval(this.photoImportLoop)
this.photoImportLoop = null
+ clearInterval(this.driveImportLoop)
+ this.driveImportLoop = null
if (this.oauthBroadcastChannel) {
this.oauthBroadcastChannel.close()
this.oauthBroadcastChannel = null
@@ -860,7 +862,6 @@ export default {
axios.get(url, { params: { sessionId: this.pickerSessionId } })
.then((response) => {
if (response.data.mediaItemsSet === true) {
- clearInterval(this.pickerPollTimer)
this.onImportPhotos()
}
})
@@ -875,6 +876,8 @@ export default {
const url = generateUrl('/apps/integration_google/import-photos')
axios.post(url, { sessionId: this.pickerSessionId })
.then((response) => {
+ clearInterval(this.pickerPollTimer)
+ this.pickerPollTimer = null
this.pickerSessionId = null
this.pickerUri = null
if (response.data.queued) {
@@ -896,6 +899,10 @@ export default {
t('integration_google', 'Failed to start importing Google Photos')
+ ': ' + error.response?.request?.responseText,
)
+ // Restart polling so the user can retry once the session is ready
+ if (this.pickerSessionId && !this.pickerPollTimer) {
+ this.pickerPollTimer = setInterval(() => this.pollPickerSession(), 5000)
+ }
})
},
/**
From fb98be315b3c5c6d8d07935cc9ebcf6952ba768f Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 20:29:07 +0100
Subject: [PATCH 21/25] fix(photos): validate queue json_decode result with
is_array() check
Mirror the same defensive pattern used in startImportPhotos(): if the
stored picker_session_queue value cannot be decoded to an array (e.g.
config corruption), fall back to an empty array rather than passing a
non-array value to array_shift().
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GooglePhotosAPIService.php | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index 8a779af2..ad858688 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -252,7 +252,10 @@ public function importPhotosJob(string $userId): void {
// so importing_photos never has a transient '0' that would stop the polling client.
if (isset($result['finished']) && $result['finished']) {
$queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
- $queue = json_decode($queueRaw, true) ?? [];
+ $queue = json_decode($queueRaw, true);
+ if (!is_array($queue)) {
+ $queue = [];
+ }
if (!empty($queue)) {
$nextSessionId = array_shift($queue);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
From c1b41e770835342192cd53e0d4cdc9fd364d99ed Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 10 Apr 2026 21:00:45 +0100
Subject: [PATCH 22/25] fix: address Copilot review comments round 10
- Cancel active Drive and Photos imports on user disconnect so
background jobs don't re-queue against deleted tokens (#106)
- Add startingPhotoImport guard in pollPickerSession() to prevent
concurrent /import-photos POST requests across poll ticks (#107)
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Controller/ConfigController.php | 4 ++++
src/components/PersonalSettings.vue | 6 +++++-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 756e7749..5858d428 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -116,6 +116,10 @@ public function setConfig(array $values): DataResponse {
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token_expires_at');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'user_scopes');
+ // Cancel any in-progress imports so background jobs don't keep re-queuing
+ // and failing against the now-deleted tokens
+ $this->googleDriveApiService->cancelImport($this->userId);
+ $this->googlePhotosApiService->cancelImport($this->userId);
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index 5810c537..58a718b3 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -411,6 +411,7 @@ export default {
importingContacts: false,
// photos (Picker API)
creatingPickerSession: false,
+ startingPhotoImport: false,
pickerSessionId: null,
pickerUri: null,
pickerPollTimer: null,
@@ -861,7 +862,7 @@ export default {
const url = generateUrl('/apps/integration_google/picker-session')
axios.get(url, { params: { sessionId: this.pickerSessionId } })
.then((response) => {
- if (response.data.mediaItemsSet === true) {
+ if (response.data.mediaItemsSet === true && !this.startingPhotoImport) {
this.onImportPhotos()
}
})
@@ -873,9 +874,11 @@ export default {
* Step 3 – User confirmed selection; trigger the background import job.
*/
onImportPhotos() {
+ this.startingPhotoImport = true
const url = generateUrl('/apps/integration_google/import-photos')
axios.post(url, { sessionId: this.pickerSessionId })
.then((response) => {
+ this.startingPhotoImport = false
clearInterval(this.pickerPollTimer)
this.pickerPollTimer = null
this.pickerSessionId = null
@@ -895,6 +898,7 @@ export default {
}
})
.catch((error) => {
+ this.startingPhotoImport = false
showError(
t('integration_google', 'Failed to start importing Google Photos')
+ ': ' + error.response?.request?.responseText,
From 18d59425f319070393b3efe3ad1c0a85e3896219 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Mon, 13 Apr 2026 04:33:08 +0100
Subject: [PATCH 23/25] fix: address Copilot review comments round 11
- Validate pollInterval from pollingConfig with Number.isFinite() and
fall back to 5000ms to prevent NaN-driven 0ms poll loop (#110)
- Reset importing_drive/photo and *_import_running flags on disconnect
so re-queued jobs exit cleanly against deleted tokens (#111)
- Null pickerPollTimer after clearInterval in onCancelPickerSession()
to keep polling-restart guards correct (#112)
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Controller/ConfigController.php | 6 ++++++
src/components/PersonalSettings.vue | 7 +++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 5858d428..5a823fc9 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -120,6 +120,12 @@ public function setConfig(array $values): DataResponse {
// and failing against the now-deleted tokens
$this->googleDriveApiService->cancelImport($this->userId);
$this->googlePhotosApiService->cancelImport($this->userId);
+ // Explicitly clear import-running flags so any currently-executing job
+ // that re-queues itself will exit on its next iteration
+ $this->userConfig->setValueString($this->userId, Application::APP_ID, 'importing_drive', '0', lazy: true);
+ $this->userConfig->setValueString($this->userId, Application::APP_ID, 'drive_import_running', '0', lazy: true);
+ $this->userConfig->setValueString($this->userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueString($this->userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index 58a718b3..a659e800 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -839,8 +839,10 @@ export default {
pickerWindow.focus()
}
// Start polling for selection completion
- const pollInterval = parseFloat(response.data.pollingConfig?.pollInterval ?? '5s') * 1000
- this.pickerPollTimer = setInterval(() => this.pollPickerSession(), Math.max(pollInterval, 4000))
+ const defaultPollInterval = 5000
+ const parsedPollInterval = parseFloat(response.data.pollingConfig?.pollInterval ?? '5s') * 1000
+ const pollInterval = Number.isFinite(parsedPollInterval) ? Math.max(parsedPollInterval, 4000) : defaultPollInterval
+ this.pickerPollTimer = setInterval(() => this.pollPickerSession(), pollInterval)
})
.catch((error) => {
showError(
@@ -914,6 +916,7 @@ export default {
*/
onCancelPickerSession() {
clearInterval(this.pickerPollTimer)
+ this.pickerPollTimer = null
const sessionId = this.pickerSessionId
this.pickerSessionId = null
this.pickerUri = null
From 3de940328f1b3664dc53f194969a59a926098161 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Fri, 17 Apr 2026 23:50:13 +0100
Subject: [PATCH 24/25] fix: address maintainer review comments (lukasdotcom)
round 1
- Fix php-cs: remove duplicate /** in GoogleDriveAPIService docblock
- Remove redundant axios config fetch from handleOAuthMessage; loadData()
already refreshes page state after OAuth (thread 62)
- Rename last_import_timestamp -> last_photo_import_timestamp for
consistency with last_drive_import_timestamp (thread 63)
- Move importing_*/import_running flag resets into cancelImport() for
both services; remove the duplicate setValueString calls from the
disconnect path in ConfigController (thread 64)
- cancelImport() now also deletes all queued picker sessions from
picker_session_queue so no stale sessions remain in Google (thread 65)
- Consolidate duplicate deletePickerSession() calls (success + error)
into a single call at the top of the terminal-state block (thread 66)
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Controller/ConfigController.php | 8 +-----
lib/Controller/GoogleAPIController.php | 2 +-
lib/Service/GoogleDriveAPIService.php | 3 ++-
lib/Service/GooglePhotosAPIService.php | 34 ++++++++++++++++----------
src/components/PersonalSettings.vue | 18 +++-----------
5 files changed, 28 insertions(+), 37 deletions(-)
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 5a823fc9..b2763b8c 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -44,7 +44,7 @@ class ConfigController extends Controller {
public const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly';
public const PHOTOS_SCOPE = 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly';
- public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_import_timestamp', 'photo_import_job_last_start'];
+ public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_photo_import_timestamp', 'photo_import_job_last_start'];
public function __construct(
string $appName,
@@ -120,12 +120,6 @@ public function setConfig(array $values): DataResponse {
// and failing against the now-deleted tokens
$this->googleDriveApiService->cancelImport($this->userId);
$this->googlePhotosApiService->cancelImport($this->userId);
- // Explicitly clear import-running flags so any currently-executing job
- // that re-queues itself will exit on its next iteration
- $this->userConfig->setValueString($this->userId, Application::APP_ID, 'importing_drive', '0', lazy: true);
- $this->userConfig->setValueString($this->userId, Application::APP_ID, 'drive_import_running', '0', lazy: true);
- $this->userConfig->setValueString($this->userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
- $this->userConfig->setValueString($this->userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php
index 9da72438..1b3472f2 100644
--- a/lib/Controller/GoogleAPIController.php
+++ b/lib/Controller/GoogleAPIController.php
@@ -61,7 +61,7 @@ public function getImportPhotosInformation(): DataResponse {
}
return new DataResponse([
'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1',
- 'last_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_import_timestamp', lazy: true),
+ 'last_photo_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_photo_import_timestamp', lazy: true),
'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true),
'nb_queued_sessions' => count($pickerSessionQueue),
]);
diff --git a/lib/Service/GoogleDriveAPIService.php b/lib/Service/GoogleDriveAPIService.php
index 70c70800..5744f3c0 100644
--- a/lib/Service/GoogleDriveAPIService.php
+++ b/lib/Service/GoogleDriveAPIService.php
@@ -154,6 +154,8 @@ public function startImportDrive(string $userId): array {
public function cancelImport(string $userId): void {
$this->jobList->remove(ImportDriveJob::class, ['user_id' => $userId]);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_drive', '0', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'drive_import_running', '0', lazy: true);
}
/**
@@ -549,7 +551,6 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder,
/**
* Create new file in the given folder with given filename
- /**
* @param array $fileItem
* @param string $userId
* @param bool $hasNameConflict
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
index ad858688..69f2d2dd 100644
--- a/lib/Service/GooglePhotosAPIService.php
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -156,7 +156,7 @@ public function startImportPhotos(string $userId, string $sessionId): array {
$this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '1', lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $sessionId, lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
@@ -172,10 +172,22 @@ public function startImportPhotos(string $userId, string $sessionId): array {
*/
public function cancelImport(string $userId): void {
$this->jobList->remove(ImportPhotosJob::class, ['user_id' => $userId]);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
$sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
if ($sessionId !== '') {
$this->deletePickerSession($userId, $sessionId);
}
+ // Also delete all queued sessions so they don't remain as stale open sessions in Google
+ $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $queue = json_decode($queueRaw, true);
+ if (is_array($queue)) {
+ foreach ($queue as $queuedSessionId) {
+ if (is_string($queuedSessionId) && $queuedSessionId !== '') {
+ $this->deletePickerSession($userId, $queuedSessionId);
+ }
+ }
+ }
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
}
@@ -228,22 +240,18 @@ public function importPhotosJob(string $userId): void {
}
if (isset($result['error']) || (isset($result['finished']) && $result['finished'])) {
+ // Clean up the picker session in both success and error cases
+ if ($sessionId !== '') {
+ $this->deletePickerSession($userId, $sessionId);
+ }
if (isset($result['finished']) && $result['finished']) {
$this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [
'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0),
'targetPath' => $targetPath,
]);
- // Clean up the picker session now that we have all items
- if ($sessionId !== '') {
- $this->deletePickerSession($userId, $sessionId);
- }
}
if (isset($result['error'])) {
$this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]);
- // Clean up the picker session on error to avoid stale sessions
- if ($sessionId !== '') {
- $this->deletePickerSession($userId, $sessionId);
- }
// Clear the queue and page token so queued sessions are not left stuck after a failure
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
@@ -261,22 +269,22 @@ public function importPhotosJob(string $userId): void {
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $nextSessionId, lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
$this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
} else {
$this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
}
} else {
$this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
}
} else {
$ts = (new DateTime())->getTimestamp();
- $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_import_timestamp', $ts, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', $ts, lazy: true);
$this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
}
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue
index a659e800..bd389c3e 100644
--- a/src/components/PersonalSettings.vue
+++ b/src/components/PersonalSettings.vue
@@ -592,19 +592,7 @@ export default {
}
console.debug('Child window message received', event)
this.state.user_name = event.data.username
- // Fetch the full config (including user_scopes) so the page
- // updates without requiring a manual refresh
- const configUrl = generateUrl('/apps/integration_google/config')
- axios.get(configUrl)
- .then((response) => {
- if (response.data) {
- Object.assign(this.state, response.data)
- }
- this.loadData()
- })
- .catch(() => {
- this.loadData()
- })
+ this.loadData()
}
// Close any previous channel before creating a new one
if (this.oauthBroadcastChannel) {
@@ -791,7 +779,7 @@ export default {
axios.get(url)
.then((response) => {
if (response.data && Object.keys(response.data).length > 0) {
- this.lastPhotoImportTimestamp = response.data.last_import_timestamp
+ this.lastPhotoImportTimestamp = response.data.last_photo_import_timestamp
this.nbImportedPhotos = response.data.nb_imported_photos
this.queuedSessions = response.data.nb_queued_sessions ?? 0
this.importingPhotos = response.data.importing_photos
@@ -935,7 +923,7 @@ export default {
const req = {
values: {
importing_photos: '0',
- last_import_timestamp: '0',
+ last_photo_import_timestamp: '0',
nb_imported_photos: '0',
},
}
From 6570bb867fd9ef2ce825d6f99b5f163d438e5742 Mon Sep 17 00:00:00 2001
From: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Date: Sat, 18 Apr 2026 00:49:32 +0100
Subject: [PATCH 25/25] fix: wrap cleanup delete/unlock calls in try/catch in
downloadAndSaveFile()
Prevents exceptions thrown during file cleanup (on LockedException,
fopen()=false, or simpleDownload error) from propagating out of the
helper and aborting the import job. The method now consistently
returns null on any failure path.
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
---
lib/Service/GoogleAPIService.php | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/lib/Service/GoogleAPIService.php b/lib/Service/GoogleAPIService.php
index 224e6aed..247baaf9 100644
--- a/lib/Service/GoogleAPIService.php
+++ b/lib/Service/GoogleAPIService.php
@@ -388,13 +388,19 @@ public function downloadAndSaveFile(
$resource = $savedFile->fopen('w');
} catch (LockedException $e) {
if ($savedFile->isDeletable()) {
- $savedFile->delete();
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
}
return null;
}
if ($resource === false) {
if ($savedFile->isDeletable()) {
- $savedFile->delete();
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
}
return null;
}
@@ -416,8 +422,14 @@ public function downloadAndSaveFile(
fclose($resource);
}
if ($savedFile->isDeletable()) {
- $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
- $savedFile->delete();
+ try {
+ $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
+ } catch (\Throwable $e) {
+ }
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
}
}
return null;