Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8dfe44b
feat(photos): replace legacy Photos API with Google Photos Picker API
AhsanIsEpic Apr 9, 2026
693ecff
chore: bump version to 4.3.2
AhsanIsEpic Apr 9, 2026
fce8b2f
chore: revert version to 4.3.1
AhsanIsEpic Apr 9, 2026
19a71c6
fix(photos): address remaining Copilot review comments
AhsanIsEpic Apr 9, 2026
bb64738
fix(photos): fix polling interval leak and progress text pluralization
AhsanIsEpic Apr 9, 2026
5c07f42
fix(photos): address Copilot review comments round 3
AhsanIsEpic Apr 9, 2026
b645069
fix(photos): address Copilot review comments round 4
AhsanIsEpic Apr 9, 2026
bb3a630
refactor(photos): replace ID-map dedup with filesystem existence check
AhsanIsEpic Apr 9, 2026
b5b2ba6
refactor(photos): use file metadata for cross-session dedup
AhsanIsEpic Apr 9, 2026
f8dc716
fix(photos): address Copilot review comments round 5
AhsanIsEpic Apr 9, 2026
a47d6ef
feat(photos): support queueing multiple picker sessions
AhsanIsEpic Apr 9, 2026
3917b1f
fix(photos): rename cancel buttons and fix picker button gap
AhsanIsEpic Apr 9, 2026
d1373d5
fix(photos): avoid transient importing_photos=0 on queue transition; …
AhsanIsEpic Apr 9, 2026
2997756
fix(photos): address Copilot review comments round 6
AhsanIsEpic Apr 9, 2026
0eb485a
fix(photos): address Copilot review comments round 7
AhsanIsEpic Apr 10, 2026
b30ecd3
fix(photos): address Copilot review comments round 8
AhsanIsEpic Apr 10, 2026
ed03d8b
fix(photos): clear picker session queue and page token on import error
AhsanIsEpic Apr 10, 2026
20f7ce3
fix(photos): address maintainer review comments
AhsanIsEpic Apr 10, 2026
c925e35
fix(photos): correct copyright year to 2026
AhsanIsEpic Apr 10, 2026
18cc183
fix(photos): address Copilot review comments round 9
AhsanIsEpic Apr 10, 2026
fb98be3
fix(photos): validate queue json_decode result with is_array() check
AhsanIsEpic Apr 10, 2026
c1b41e7
fix: address Copilot review comments round 10
AhsanIsEpic Apr 10, 2026
18d5942
fix: address Copilot review comments round 11
AhsanIsEpic Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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' => 'POST'],
['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'],
]
Expand Down
38 changes: 38 additions & 0 deletions lib/BackgroundJob/ImportPhotosJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* Nextcloud - integration_google
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Ahsan Ahmed
* @copyright Nextcloud GmbH and Nextcloud contributors 2026
*/

namespace OCA\Google\BackgroundJob;

use OCA\Google\Service\GooglePhotosAPIService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;

/**
* A QueuedJob to partially import google photos and launch following job
*/
class ImportPhotosJob extends QueuedJob {

public function __construct(
ITimeFactory $timeFactory,
private GooglePhotosAPIService $service,
) {
parent::__construct($timeFactory);
}

/**
* @param array{user_id:string} $argument
*/
public function run($argument) {
$userId = $argument['user_id'];
$this->service->importPhotosJob($userId);
}
}
42 changes: 41 additions & 1 deletion lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -55,13 +57,36 @@ public function __construct(
private IInitialState $initialStateService,
private GoogleAPIService $googleApiService,
private GoogleDriveAPIService $googleDriveApiService,
private GooglePhotosAPIService $googlePhotosApiService,
private ?string $userId,
private ICrypto $crypto,
private SecretService $secretService,
) {
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
Expand Down Expand Up @@ -90,6 +115,17 @@ 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');
// 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);
// 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'])) {
Expand All @@ -100,6 +136,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);
}
Expand Down Expand Up @@ -192,6 +231,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);
Expand Down
102 changes: 102 additions & 0 deletions lib/Controller/GoogleAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -40,6 +42,106 @@ 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);
}
$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($pickerSessionQueue),
]);
}

/**
* @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
*
Expand Down
12 changes: 12 additions & 0 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
70 changes: 70 additions & 0 deletions lib/Service/GoogleAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -353,6 +358,71 @@ 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) {
if ($savedFile->isDeletable()) {
$savedFile->delete();
}
return null;
Comment on lines +387 to +393
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the LockedException cleanup path, calling $savedFile->delete() can itself throw (e.g. if the file is still locked or not permitted). Since this helper returns ?int and is used by background imports, any thrown exception will abort the import/job instead of returning null. Wrap the cleanup delete (and any unlock if needed) in a try/catch so failures during cleanup don’t propagate.

Copilot uses AI. Check for mistakes.
}
if ($resource === false) {
if ($savedFile->isDeletable()) {
$savedFile->delete();
}
return null;
Comment on lines +395 to +399
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When $savedFile->fopen('w') returns false, the cleanup branch deletes the newly created file but does not guard against exceptions from delete(). If delete() throws, this helper will throw unexpectedly and can abort the import. Consider wrapping the delete in try/catch (and optionally attempting unlock) to ensure this method consistently returns null on failure.

Copilot uses AI. Check for mistakes.
}

$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();
Comment on lines +419 to +420
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the simpleDownload error path, unlock()+delete() are called without any exception handling. If unlock/delete fails (e.g. lock contention or permissions), the exception will bubble up and can crash the importer instead of gracefully returning null. Wrap unlock/delete in try/catch (and consider skipping unlock if it fails) so cleanup errors don’t abort the job.

Suggested change
$savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
$savedFile->delete();
try {
$savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
} catch (\Throwable $e) {
}
try {
$savedFile->delete();
} catch (\Throwable $e) {
}

Copilot uses AI. Check for mistakes.
}
}
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);
Expand Down
Loading
Loading